smartproxy/ts/classes.portproxy.ts

1354 lines
55 KiB
TypeScript
Raw Normal View History

import * as plugins from './plugins.js';
/** Domain configuration with per-domain allowed port ranges */
export interface IDomainConfig {
domains: string[]; // Glob patterns for domain(s)
allowedIPs: string[]; // Glob patterns for allowed IPs
blockedIPs?: string[]; // Glob patterns for blocked IPs
targetIPs?: string[]; // If multiple targetIPs are given, use round robin.
portRanges?: Array<{ from: number; to: number }>; // Optional port ranges
// Protocol-specific timeout overrides
httpTimeout?: number; // HTTP connection timeout override (ms)
wsTimeout?: number; // WebSocket connection timeout override (ms)
}
/** Connection protocol types for timeout management */
export type ProtocolType = 'http' | 'websocket' | 'https' | 'tls' | 'unknown';
/** Port proxy settings including global allowed port ranges */
export interface IPortProxySettings extends plugins.tls.TlsOptions {
fromPort: number;
toPort: number;
targetIP?: string; // Global target host to proxy to, defaults to 'localhost'
domainConfigs: IDomainConfig[];
sniEnabled?: boolean;
defaultAllowedIPs?: string[];
defaultBlockedIPs?: string[];
preserveSourceIP?: boolean;
// Updated timeout settings with better defaults
initialDataTimeout?: number; // Timeout for initial data/SNI (ms), default: 15000 (15s)
socketTimeout?: number; // Socket inactivity timeout (ms), default: 300000 (5m)
inactivityCheckInterval?: number; // How often to check for inactive connections (ms), default: 30000 (30s)
// Protocol-specific timeouts
maxConnectionLifetime?: number; // Default max connection lifetime (ms), default: 3600000 (1h)
httpConnectionTimeout?: number; // HTTP specific timeout (ms), default: 1800000 (30m)
wsConnectionTimeout?: number; // WebSocket specific timeout (ms), default: 14400000 (4h)
httpKeepAliveTimeout?: number; // HTTP keep-alive header timeout (ms), default: 1200000 (20m)
gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown
globalPortRanges: Array<{ from: number; to: number }>; // Global allowed port ranges
forwardAllGlobalRanges?: boolean; // When true, forwards all connections on global port ranges to the global targetIP
// Socket optimization settings
noDelay?: boolean; // Disable Nagle's algorithm (default: true)
keepAlive?: boolean; // Enable TCP keepalive (default: true)
keepAliveInitialDelay?: number; // Initial delay before sending keepalive probes (ms)
maxPendingDataSize?: number; // Maximum bytes to buffer during connection setup
// Enable enhanced features
disableInactivityCheck?: boolean; // Disable inactivity checking entirely
enableKeepAliveProbes?: boolean; // Enable TCP keep-alive probes
enableProtocolDetection?: boolean; // Enable HTTP/WebSocket protocol detection
enableDetailedLogging?: boolean; // Enable detailed connection logging
// Rate limiting and security
maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP
connectionRateLimitPerMinute?: number; // Max new connections per minute from a single IP
}
/**
* Enhanced connection record with protocol-specific handling
*/
interface IConnectionRecord {
id: string; // Unique connection identifier
incoming: plugins.net.Socket;
outgoing: plugins.net.Socket | null;
incomingStartTime: number;
outgoingStartTime?: number;
outgoingClosedTime?: number;
lockedDomain?: string; // Used to lock this connection to the initial SNI
connectionClosed: boolean; // Flag to prevent multiple cleanup attempts
cleanupTimer?: NodeJS.Timeout; // Timer for max lifetime/inactivity
lastActivity: number; // Last activity timestamp for inactivity detection
pendingData: Buffer[]; // Buffer to hold data during connection setup
pendingDataSize: number; // Track total size of pending data
// Enhanced tracking fields
protocolType: ProtocolType; // Connection protocol type
isPooledConnection: boolean; // Whether this is likely a browser pooled connection
lastHttpRequest?: number; // Timestamp of last HTTP request (for keep-alive tracking)
httpKeepAliveTimeout?: number; // HTTP keep-alive timeout from headers
bytesReceived: number; // Total bytes received
bytesSent: number; // Total bytes sent
remoteIP: string; // Remote IP (cached for logging after socket close)
localPort: number; // Local port (cached for logging)
httpRequests: number; // Count of HTTP requests on this connection
}
/**
* Extracts the SNI (Server Name Indication) from a TLS ClientHello packet.
* @param buffer - Buffer containing the TLS ClientHello.
* @returns The server name if found, otherwise undefined.
*/
function extractSNI(buffer: Buffer): string | undefined {
let offset = 0;
if (buffer.length < 5) return undefined;
const recordType = buffer.readUInt8(0);
if (recordType !== 22) return undefined; // 22 = handshake
const recordLength = buffer.readUInt16BE(3);
if (buffer.length < 5 + recordLength) return undefined;
offset = 5;
const handshakeType = buffer.readUInt8(offset);
if (handshakeType !== 1) return undefined; // 1 = ClientHello
offset += 4; // Skip handshake header (type + length)
offset += 2 + 32; // Skip client version and random
const sessionIDLength = buffer.readUInt8(offset);
offset += 1 + sessionIDLength; // Skip session ID
const cipherSuitesLength = buffer.readUInt16BE(offset);
offset += 2 + cipherSuitesLength; // Skip cipher suites
const compressionMethodsLength = buffer.readUInt8(offset);
offset += 1 + compressionMethodsLength; // Skip compression methods
if (offset + 2 > buffer.length) return undefined;
const extensionsLength = buffer.readUInt16BE(offset);
offset += 2;
const extensionsEnd = offset + extensionsLength;
while (offset + 4 <= extensionsEnd) {
const extensionType = buffer.readUInt16BE(offset);
const extensionLength = buffer.readUInt16BE(offset + 2);
offset += 4;
if (extensionType === 0x0000) { // SNI extension
if (offset + 2 > buffer.length) return undefined;
const sniListLength = buffer.readUInt16BE(offset);
offset += 2;
const sniListEnd = offset + sniListLength;
while (offset + 3 < sniListEnd) {
const nameType = buffer.readUInt8(offset++);
const nameLen = buffer.readUInt16BE(offset);
offset += 2;
if (nameType === 0) { // host_name
if (offset + nameLen > buffer.length) return undefined;
return buffer.toString('utf8', offset, offset + nameLen);
}
offset += nameLen;
}
break;
} else {
offset += extensionLength;
}
}
return undefined;
}
2019-08-22 15:09:48 +02:00
// Helper: Check if a port falls within any of the given port ranges
const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }>): boolean => {
return ranges.some(range => port >= range.from && port <= range.to);
};
// Helper: Check if a given IP matches any of the glob patterns
const isAllowed = (ip: string, patterns: string[]): boolean => {
const normalizeIP = (ip: string): string[] => {
if (ip.startsWith('::ffff:')) {
const ipv4 = ip.slice(7);
return [ip, ipv4];
}
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
return [ip, `::ffff:${ip}`];
}
return [ip];
};
const normalizedIPVariants = normalizeIP(ip);
const expandedPatterns = patterns.flatMap(normalizeIP);
return normalizedIPVariants.some(ipVariant =>
expandedPatterns.some(pattern => plugins.minimatch(ipVariant, pattern))
);
};
// Helper: Check if an IP is allowed considering allowed and blocked glob patterns
const isGlobIPAllowed = (ip: string, allowed: string[], blocked: string[] = []): boolean => {
if (blocked.length > 0 && isAllowed(ip, blocked)) return false;
return isAllowed(ip, allowed);
};
// Helper: Generate a unique connection ID
const generateConnectionId = (): string => {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
};
// Protocol detection helpers
const isHttpRequest = (buffer: Buffer): boolean => {
if (buffer.length < 4) return false;
const start = buffer.toString('ascii', 0, 4).toUpperCase();
return (
start.startsWith('GET ') ||
start.startsWith('POST') ||
start.startsWith('PUT ') ||
start.startsWith('HEAD') ||
start.startsWith('DELE') ||
start.startsWith('PATC') ||
start.startsWith('OPTI')
);
};
const isWebSocketUpgrade = (buffer: Buffer): boolean => {
if (buffer.length < 20) return false;
const data = buffer.toString('ascii', 0, Math.min(buffer.length, 200));
return (
data.includes('Upgrade: websocket') ||
data.includes('Upgrade: WebSocket')
);
};
const isTlsHandshake = (buffer: Buffer): boolean => {
return buffer.length > 0 && buffer[0] === 22; // ContentType.handshake
};
2022-07-29 00:49:46 +02:00
export class PortProxy {
private netServers: plugins.net.Server[] = [];
settings: IPortProxySettings;
private connectionRecords: Map<string, IConnectionRecord> = new Map();
private connectionLogger: NodeJS.Timeout | null = null;
private isShuttingDown: boolean = false;
2022-07-29 00:49:46 +02:00
// Map to track round robin indices for each domain config
private domainTargetIndices: Map<IDomainConfig, number> = new Map();
// Enhanced stats tracking
private terminationStats: {
incoming: Record<string, number>;
outgoing: Record<string, number>;
} = {
incoming: {},
outgoing: {},
};
// Connection tracking by IP for rate limiting
private connectionsByIP: Map<string, Set<string>> = new Map();
private connectionRateByIP: Map<string, number[]> = new Map();
constructor(settingsArg: IPortProxySettings) {
// Set reasonable defaults for all settings
this.settings = {
...settingsArg,
targetIP: settingsArg.targetIP || 'localhost',
// Timeout settings with browser-friendly defaults
initialDataTimeout: settingsArg.initialDataTimeout || 30000, // 30 seconds
socketTimeout: settingsArg.socketTimeout || 300000, // 5 minutes
inactivityCheckInterval: settingsArg.inactivityCheckInterval || 30000, // 30 seconds
// Protocol-specific timeouts
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 3600000, // 1 hour default
httpConnectionTimeout: settingsArg.httpConnectionTimeout || 1800000, // 30 minutes
wsConnectionTimeout: settingsArg.wsConnectionTimeout || 14400000, // 4 hours
httpKeepAliveTimeout: settingsArg.httpKeepAliveTimeout || 1200000, // 20 minutes
gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000, // 30 seconds
// Socket optimization settings
noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true,
keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true,
keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 30000, // 30 seconds
maxPendingDataSize: settingsArg.maxPendingDataSize || 1024 * 1024, // 1MB
// Feature flags
disableInactivityCheck: settingsArg.disableInactivityCheck || false,
enableKeepAliveProbes: settingsArg.enableKeepAliveProbes || false,
enableProtocolDetection: settingsArg.enableProtocolDetection !== undefined ? settingsArg.enableProtocolDetection : true,
enableDetailedLogging: settingsArg.enableDetailedLogging || false,
// Rate limiting defaults
maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100, // 100 connections per IP
connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300, // 300 per minute
};
2022-07-29 00:49:46 +02:00
}
/**
* Get connections count by IP
*/
private getConnectionCountByIP(ip: string): number {
return this.connectionsByIP.get(ip)?.size || 0;
}
/**
* Check and update connection rate for an IP
*/
private checkConnectionRate(ip: string): boolean {
const now = Date.now();
const minute = 60 * 1000;
if (!this.connectionRateByIP.has(ip)) {
this.connectionRateByIP.set(ip, [now]);
return true;
}
// Get timestamps and filter out entries older than 1 minute
const timestamps = this.connectionRateByIP.get(ip)!.filter(time => now - time < minute);
timestamps.push(now);
this.connectionRateByIP.set(ip, timestamps);
// Check if rate exceeds limit
return timestamps.length <= this.settings.connectionRateLimitPerMinute!;
}
/**
* Track connection by IP
*/
private trackConnectionByIP(ip: string, connectionId: string): void {
if (!this.connectionsByIP.has(ip)) {
this.connectionsByIP.set(ip, new Set());
}
this.connectionsByIP.get(ip)!.add(connectionId);
}
/**
* Remove connection tracking for an IP
*/
private removeConnectionByIP(ip: string, connectionId: string): void {
if (this.connectionsByIP.has(ip)) {
const connections = this.connectionsByIP.get(ip)!;
connections.delete(connectionId);
if (connections.size === 0) {
this.connectionsByIP.delete(ip);
}
}
}
/**
* Track connection termination statistic
*/
private incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void {
this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1;
}
/**
* Get protocol-specific timeout based on connection type
*/
private getProtocolTimeout(record: IConnectionRecord, domainConfig?: IDomainConfig): number {
// If the protocol has a domain-specific timeout, use that
if (domainConfig) {
if (record.protocolType === 'http' && domainConfig.httpTimeout) {
return domainConfig.httpTimeout;
}
if (record.protocolType === 'websocket' && domainConfig.wsTimeout) {
return domainConfig.wsTimeout;
}
}
// Use HTTP keep-alive timeout from headers if available
if (record.httpKeepAliveTimeout) {
return record.httpKeepAliveTimeout;
}
// Otherwise use default protocol-specific timeout
switch (record.protocolType) {
case 'http':
return this.settings.httpConnectionTimeout!;
case 'websocket':
return this.settings.wsConnectionTimeout!;
case 'https':
case 'tls':
return this.settings.httpConnectionTimeout!; // Use HTTP timeout for HTTPS by default
default:
return this.settings.maxConnectionLifetime!;
}
}
/**
* Detect protocol and update connection record
*/
private detectProtocol(data: Buffer, record: IConnectionRecord): void {
if (!this.settings.enableProtocolDetection || record.protocolType !== 'unknown') {
return;
}
try {
// Detect TLS/HTTPS
if (isTlsHandshake(data)) {
record.protocolType = 'tls';
if (this.settings.enableDetailedLogging) {
console.log(`[${record.id}] Protocol detected: TLS`);
}
return;
}
// Detect HTTP including WebSocket upgrades
if (isHttpRequest(data)) {
record.httpRequests++;
record.lastHttpRequest = Date.now();
// Check for WebSocket upgrade
if (isWebSocketUpgrade(data)) {
record.protocolType = 'websocket';
if (this.settings.enableDetailedLogging) {
console.log(`[${record.id}] Protocol detected: WebSocket Upgrade`);
}
} else {
record.protocolType = 'http';
// Parse HTTP keep-alive headers
this.parseHttpHeaders(data, record);
if (this.settings.enableDetailedLogging) {
console.log(`[${record.id}] Protocol detected: HTTP${record.isPooledConnection ? ' (KeepAlive)' : ''}`);
}
}
}
} catch (err) {
console.log(`[${record.id}] Error detecting protocol: ${err}`);
}
}
/**
* Parse HTTP headers for keep-alive and other connection info
*/
private parseHttpHeaders(data: Buffer, record: IConnectionRecord): void {
try {
const headerStr = data.toString('utf8', 0, Math.min(data.length, 1024));
// Check for HTTP keep-alive
const connectionHeader = headerStr.match(/\r\nConnection:\s*([^\r\n]+)/i);
if (connectionHeader && connectionHeader[1].toLowerCase().includes('keep-alive')) {
record.isPooledConnection = true;
// Check for Keep-Alive timeout value
const keepAliveHeader = headerStr.match(/\r\nKeep-Alive:\s*([^\r\n]+)/i);
if (keepAliveHeader) {
const timeoutMatch = keepAliveHeader[1].match(/timeout=(\d+)/i);
if (timeoutMatch && timeoutMatch[1]) {
const timeoutSec = parseInt(timeoutMatch[1], 10);
if (!isNaN(timeoutSec) && timeoutSec > 0) {
// Convert seconds to milliseconds and add some buffer
record.httpKeepAliveTimeout = (timeoutSec * 1000) + 5000;
if (this.settings.enableDetailedLogging) {
console.log(`[${record.id}] HTTP Keep-Alive timeout set to ${timeoutSec} seconds`);
}
}
}
}
}
} catch (err) {
console.log(`[${record.id}] Error parsing HTTP headers: ${err}`);
}
}
/**
* Cleans up a connection record.
* Destroys both incoming and outgoing sockets, clears timers, and removes the record.
* @param record - The connection record to clean up
* @param reason - Optional reason for cleanup (for logging)
*/
private cleanupConnection(record: IConnectionRecord, reason: string = 'normal'): void {
if (!record.connectionClosed) {
record.connectionClosed = true;
// Track connection termination
this.removeConnectionByIP(record.remoteIP, record.id);
if (record.cleanupTimer) {
clearTimeout(record.cleanupTimer);
record.cleanupTimer = undefined;
}
// Detailed logging data
const duration = Date.now() - record.incomingStartTime;
const bytesReceived = record.bytesReceived;
const bytesSent = record.bytesSent;
const httpRequests = record.httpRequests;
try {
if (!record.incoming.destroyed) {
// Try graceful shutdown first, then force destroy after a short timeout
record.incoming.end();
const incomingTimeout = setTimeout(() => {
try {
if (record && !record.incoming.destroyed) {
record.incoming.destroy();
}
} catch (err) {
console.log(`[${record.id}] Error destroying incoming socket: ${err}`);
}
}, 1000);
// Ensure the timeout doesn't block Node from exiting
if (incomingTimeout.unref) {
incomingTimeout.unref();
}
}
} catch (err) {
console.log(`[${record.id}] Error closing incoming socket: ${err}`);
try {
if (!record.incoming.destroyed) {
record.incoming.destroy();
}
} catch (destroyErr) {
console.log(`[${record.id}] Error destroying incoming socket: ${destroyErr}`);
}
}
try {
if (record.outgoing && !record.outgoing.destroyed) {
// Try graceful shutdown first, then force destroy after a short timeout
record.outgoing.end();
const outgoingTimeout = setTimeout(() => {
try {
if (record && record.outgoing && !record.outgoing.destroyed) {
record.outgoing.destroy();
}
} catch (err) {
console.log(`[${record.id}] Error destroying outgoing socket: ${err}`);
}
}, 1000);
// Ensure the timeout doesn't block Node from exiting
if (outgoingTimeout.unref) {
outgoingTimeout.unref();
}
}
} catch (err) {
console.log(`[${record.id}] Error closing outgoing socket: ${err}`);
try {
if (record.outgoing && !record.outgoing.destroyed) {
record.outgoing.destroy();
}
} catch (destroyErr) {
console.log(`[${record.id}] Error destroying outgoing socket: ${destroyErr}`);
}
}
// Clear pendingData to avoid memory leaks
record.pendingData = [];
record.pendingDataSize = 0;
// Remove the record from the tracking map
this.connectionRecords.delete(record.id);
// Log connection details
if (this.settings.enableDetailedLogging) {
console.log(`[${record.id}] Connection from ${record.remoteIP} on port ${record.localPort} terminated (${reason}).` +
` Duration: ${plugins.prettyMs(duration)}, Bytes IN: ${bytesReceived}, OUT: ${bytesSent}, ` +
`HTTP Requests: ${httpRequests}, Protocol: ${record.protocolType}, Pooled: ${record.isPooledConnection}`);
} else {
console.log(`[${record.id}] Connection from ${record.remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}`);
}
}
}
/**
* Update connection activity timestamp
*/
private updateActivity(record: IConnectionRecord): void {
record.lastActivity = Date.now();
}
/**
* Get target IP with round-robin support
*/
private getTargetIP(domainConfig: IDomainConfig): string {
if (domainConfig.targetIPs && domainConfig.targetIPs.length > 0) {
const currentIndex = this.domainTargetIndices.get(domainConfig) || 0;
const ip = domainConfig.targetIPs[currentIndex % domainConfig.targetIPs.length];
this.domainTargetIndices.set(domainConfig, currentIndex + 1);
return ip;
}
return this.settings.targetIP!;
}
/**
* Main method to start the proxy
*/
2022-07-29 01:52:34 +02:00
public async start() {
// Don't start if already shutting down
if (this.isShuttingDown) {
console.log("Cannot start PortProxy while it's shutting down");
return;
}
// Define a unified connection handler for all listening ports.
const connectionHandler = (socket: plugins.net.Socket) => {
if (this.isShuttingDown) {
socket.end();
socket.destroy();
return;
}
const remoteIP = socket.remoteAddress || '';
const localPort = socket.localPort || 0; // The port on which this connection was accepted.
// Check rate limits
if (this.settings.maxConnectionsPerIP &&
this.getConnectionCountByIP(remoteIP) >= this.settings.maxConnectionsPerIP) {
console.log(`Connection rejected from ${remoteIP}: Maximum connections per IP (${this.settings.maxConnectionsPerIP}) exceeded`);
socket.end();
socket.destroy();
return;
}
if (this.settings.connectionRateLimitPerMinute && !this.checkConnectionRate(remoteIP)) {
console.log(`Connection rejected from ${remoteIP}: Connection rate limit (${this.settings.connectionRateLimitPerMinute}/min) exceeded`);
socket.end();
socket.destroy();
return;
}
// Apply socket optimizations
socket.setNoDelay(this.settings.noDelay);
if (this.settings.keepAlive) {
socket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
}
// Create a unique connection ID and record
const connectionId = generateConnectionId();
const connectionRecord: IConnectionRecord = {
id: connectionId,
incoming: socket,
outgoing: null,
incomingStartTime: Date.now(),
lastActivity: Date.now(),
connectionClosed: false,
pendingData: [],
pendingDataSize: 0,
// Initialize enhanced tracking fields
protocolType: 'unknown',
isPooledConnection: false,
bytesReceived: 0,
bytesSent: 0,
remoteIP: remoteIP,
localPort: localPort,
httpRequests: 0
};
// Track connection by IP
this.trackConnectionByIP(remoteIP, connectionId);
this.connectionRecords.set(connectionId, connectionRecord);
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionRecords.size}`);
} else {
console.log(`New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionRecords.size}`);
}
let initialDataReceived = false;
let incomingTerminationReason: string | null = null;
let outgoingTerminationReason: string | null = null;
// Local function for cleanupOnce
const cleanupOnce = () => {
this.cleanupConnection(connectionRecord);
};
// Define initiateCleanupOnce for compatibility
const initiateCleanupOnce = (reason: string = 'normal') => {
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Connection cleanup initiated for ${remoteIP} (${reason})`);
}
if (incomingTerminationReason === null) {
incomingTerminationReason = reason;
this.incrementTerminationStat('incoming', reason);
}
cleanupOnce();
};
// Helper to reject an incoming connection
const rejectIncomingConnection = (reason: string, logMessage: string) => {
console.log(`[${connectionId}] ${logMessage}`);
socket.end();
if (incomingTerminationReason === null) {
incomingTerminationReason = reason;
this.incrementTerminationStat('incoming', reason);
}
cleanupOnce();
};
// Set an initial timeout for SNI data if needed
let initialTimeout: NodeJS.Timeout | null = null;
if (this.settings.sniEnabled) {
initialTimeout = setTimeout(() => {
if (!initialDataReceived) {
console.log(`[${connectionId}] Initial data timeout (${this.settings.initialDataTimeout}ms) for connection from ${remoteIP} on port ${localPort}`);
if (incomingTerminationReason === null) {
incomingTerminationReason = 'initial_timeout';
this.incrementTerminationStat('incoming', 'initial_timeout');
}
socket.end();
cleanupOnce();
}
}, this.settings.initialDataTimeout);
} else {
initialDataReceived = true;
}
socket.on('error', (err: Error) => {
console.log(`[${connectionId}] Incoming socket error from ${remoteIP}: ${err.message}`);
});
// Track data for bytes counting
socket.on('data', (chunk: Buffer) => {
connectionRecord.bytesReceived += chunk.length;
this.updateActivity(connectionRecord);
// Detect protocol on first data chunk
if (connectionRecord.protocolType === 'unknown') {
this.detectProtocol(chunk, connectionRecord);
// Update timeout based on protocol
if (connectionRecord.cleanupTimer) {
clearTimeout(connectionRecord.cleanupTimer);
// Set new timeout based on protocol
const protocolTimeout = this.getProtocolTimeout(connectionRecord);
connectionRecord.cleanupTimer = setTimeout(() => {
console.log(`[${connectionId}] ${connectionRecord.protocolType} connection timeout after ${plugins.prettyMs(protocolTimeout)}`);
initiateCleanupOnce(`${connectionRecord.protocolType}_timeout`);
}, protocolTimeout);
}
} else if (connectionRecord.protocolType === 'http' && isHttpRequest(chunk)) {
// Additional HTTP request on the same connection
connectionRecord.httpRequests++;
connectionRecord.lastHttpRequest = Date.now();
// Parse HTTP headers again for keep-alive changes
this.parseHttpHeaders(chunk, connectionRecord);
// Update timeout based on new HTTP headers
if (connectionRecord.cleanupTimer) {
clearTimeout(connectionRecord.cleanupTimer);
// Set new timeout based on updated HTTP info
const protocolTimeout = this.getProtocolTimeout(connectionRecord);
connectionRecord.cleanupTimer = setTimeout(() => {
console.log(`[${connectionId}] HTTP connection timeout after ${plugins.prettyMs(protocolTimeout)}`);
initiateCleanupOnce('http_timeout');
}, protocolTimeout);
}
}
});
const handleError = (side: 'incoming' | 'outgoing') => (err: Error) => {
const code = (err as any).code;
let reason = 'error';
const now = Date.now();
const connectionDuration = now - connectionRecord.incomingStartTime;
const lastActivityAge = now - connectionRecord.lastActivity;
if (code === 'ECONNRESET') {
reason = 'econnreset';
console.log(`[${connectionId}] ECONNRESET on ${side} side from ${remoteIP}: ${err.message}. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)} ago`);
} else if (code === 'ETIMEDOUT') {
reason = 'etimedout';
console.log(`[${connectionId}] ETIMEDOUT on ${side} side from ${remoteIP}: ${err.message}. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)} ago`);
} else {
console.log(`[${connectionId}] Error on ${side} side from ${remoteIP}: ${err.message}. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)} ago`);
}
if (side === 'incoming' && incomingTerminationReason === null) {
incomingTerminationReason = reason;
this.incrementTerminationStat('incoming', reason);
} else if (side === 'outgoing' && outgoingTerminationReason === null) {
outgoingTerminationReason = reason;
this.incrementTerminationStat('outgoing', reason);
}
initiateCleanupOnce(reason);
};
const handleClose = (side: 'incoming' | 'outgoing') => () => {
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Connection closed on ${side} side from ${remoteIP}`);
}
if (side === 'incoming' && incomingTerminationReason === null) {
incomingTerminationReason = 'normal';
this.incrementTerminationStat('incoming', 'normal');
} else if (side === 'outgoing' && outgoingTerminationReason === null) {
outgoingTerminationReason = 'normal';
this.incrementTerminationStat('outgoing', 'normal');
// Record the time when outgoing socket closed.
connectionRecord.outgoingClosedTime = Date.now();
}
initiateCleanupOnce('closed_' + side);
};
/**
* Sets up the connection to the target host.
* @param serverName - The SNI hostname (unused when forcedDomain is provided).
* @param initialChunk - Optional initial data chunk.
* @param forcedDomain - If provided, overrides SNI/domain lookup (used for port-based routing).
* @param overridePort - If provided, use this port for the outgoing connection.
*/
const setupConnection = (serverName: string, initialChunk?: Buffer, forcedDomain?: IDomainConfig, overridePort?: number) => {
// Clear the initial timeout since we've received data
if (initialTimeout) {
clearTimeout(initialTimeout);
initialTimeout = null;
}
// Detect protocol if initial chunk is available
if (initialChunk && this.settings.enableProtocolDetection) {
this.detectProtocol(initialChunk, connectionRecord);
}
// If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
const domainConfig = forcedDomain
? forcedDomain
: (serverName ? this.settings.domainConfigs.find(config =>
config.domains.some(d => plugins.minimatch(serverName, d))
) : undefined);
// IP validation is skipped if allowedIPs is empty
if (domainConfig) {
const effectiveAllowedIPs: string[] = [
...domainConfig.allowedIPs,
...(this.settings.defaultAllowedIPs || [])
];
const effectiveBlockedIPs: string[] = [
...(domainConfig.blockedIPs || []),
...(this.settings.defaultBlockedIPs || [])
];
// Skip IP validation if allowedIPs is empty
if (domainConfig.allowedIPs.length > 0 && !isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) {
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for domain ${domainConfig.domains.join(', ')}`);
}
} else if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0) {
if (!isGlobIPAllowed(remoteIP, this.settings.defaultAllowedIPs, this.settings.defaultBlockedIPs || [])) {
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed by default allowed list`);
}
}
const targetHost = domainConfig ? this.getTargetIP(domainConfig) : this.settings.targetIP!;
const connectionOptions: plugins.net.NetConnectOpts = {
host: targetHost,
port: overridePort !== undefined ? overridePort : this.settings.toPort,
};
if (this.settings.preserveSourceIP) {
connectionOptions.localAddress = remoteIP.replace('::ffff:', '');
}
// Pause the incoming socket to prevent buffer overflows
socket.pause();
// Temporary handler to collect data during connection setup
const tempDataHandler = (chunk: Buffer) => {
// Track bytes received
connectionRecord.bytesReceived += chunk.length;
// Detect protocol even during connection setup
if (this.settings.enableProtocolDetection && connectionRecord.protocolType === 'unknown') {
this.detectProtocol(chunk, connectionRecord);
}
// Check if adding this chunk would exceed the buffer limit
const newSize = connectionRecord.pendingDataSize + chunk.length;
if (this.settings.maxPendingDataSize && newSize > this.settings.maxPendingDataSize) {
console.log(`[${connectionId}] Buffer limit exceeded for connection from ${remoteIP}: ${newSize} bytes > ${this.settings.maxPendingDataSize} bytes`);
socket.end(); // Gracefully close the socket
return initiateCleanupOnce('buffer_limit_exceeded');
}
// Buffer the chunk and update the size counter
connectionRecord.pendingData.push(Buffer.from(chunk));
connectionRecord.pendingDataSize = newSize;
this.updateActivity(connectionRecord);
};
// Add the temp handler to capture all incoming data during connection setup
socket.on('data', tempDataHandler);
// Add initial chunk to pending data if present
if (initialChunk) {
connectionRecord.bytesReceived += initialChunk.length;
connectionRecord.pendingData.push(Buffer.from(initialChunk));
connectionRecord.pendingDataSize = initialChunk.length;
}
// Create the target socket but don't set up piping immediately
const targetSocket = plugins.net.connect(connectionOptions);
connectionRecord.outgoing = targetSocket;
connectionRecord.outgoingStartTime = Date.now();
// Apply socket optimizations
targetSocket.setNoDelay(this.settings.noDelay);
if (this.settings.keepAlive) {
targetSocket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
}
// Setup specific error handler for connection phase
targetSocket.once('error', (err) => {
// This handler runs only once during the initial connection phase
const code = (err as any).code;
console.log(`[${connectionId}] Connection setup error to ${targetHost}:${connectionOptions.port}: ${err.message} (${code})`);
// Resume the incoming socket to prevent it from hanging
socket.resume();
if (code === 'ECONNREFUSED') {
console.log(`[${connectionId}] Target ${targetHost}:${connectionOptions.port} refused connection`);
} else if (code === 'ETIMEDOUT') {
console.log(`[${connectionId}] Connection to ${targetHost}:${connectionOptions.port} timed out`);
} else if (code === 'ECONNRESET') {
console.log(`[${connectionId}] Connection to ${targetHost}:${connectionOptions.port} was reset`);
} else if (code === 'EHOSTUNREACH') {
console.log(`[${connectionId}] Host ${targetHost} is unreachable`);
}
// Clear any existing error handler after connection phase
targetSocket.removeAllListeners('error');
// Re-add the normal error handler for established connections
targetSocket.on('error', handleError('outgoing'));
if (outgoingTerminationReason === null) {
outgoingTerminationReason = 'connection_failed';
this.incrementTerminationStat('outgoing', 'connection_failed');
}
// Clean up the connection
initiateCleanupOnce(`connection_failed_${code}`);
});
// Setup close handler
targetSocket.on('close', handleClose('outgoing'));
socket.on('close', handleClose('incoming'));
// Handle timeouts
socket.on('timeout', () => {
console.log(`[${connectionId}] Timeout on incoming side from ${remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 300000)}`);
if (incomingTerminationReason === null) {
incomingTerminationReason = 'timeout';
this.incrementTerminationStat('incoming', 'timeout');
}
initiateCleanupOnce('timeout_incoming');
});
targetSocket.on('timeout', () => {
console.log(`[${connectionId}] Timeout on outgoing side from ${remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 300000)}`);
if (outgoingTerminationReason === null) {
outgoingTerminationReason = 'timeout';
this.incrementTerminationStat('outgoing', 'timeout');
}
initiateCleanupOnce('timeout_outgoing');
});
// Set appropriate timeouts using the configured value
socket.setTimeout(this.settings.socketTimeout || 300000);
targetSocket.setTimeout(this.settings.socketTimeout || 300000);
// Track outgoing data for bytes counting
targetSocket.on('data', (chunk: Buffer) => {
connectionRecord.bytesSent += chunk.length;
this.updateActivity(connectionRecord);
});
// Wait for the outgoing connection to be ready before setting up piping
targetSocket.once('connect', () => {
// Clear the initial connection error handler
targetSocket.removeAllListeners('error');
// Add the normal error handler for established connections
targetSocket.on('error', handleError('outgoing'));
// Remove temporary data handler
socket.removeListener('data', tempDataHandler);
// Flush all pending data to target
if (connectionRecord.pendingData.length > 0) {
const combinedData = Buffer.concat(connectionRecord.pendingData);
targetSocket.write(combinedData, (err) => {
if (err) {
console.log(`[${connectionId}] Error writing pending data to target: ${err.message}`);
return initiateCleanupOnce('write_error');
}
// Now set up piping for future data and resume the socket
socket.pipe(targetSocket);
targetSocket.pipe(socket);
socket.resume(); // Resume the socket after piping is established
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}` +
` Protocol: ${connectionRecord.protocolType}`
);
} else {
console.log(
`Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}`
);
}
});
} else {
// No pending data, so just set up piping
socket.pipe(targetSocket);
targetSocket.pipe(socket);
socket.resume(); // Resume the socket after piping is established
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}` +
` Protocol: ${connectionRecord.protocolType}`
);
} else {
console.log(
`Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}`
);
}
}
// Clear the buffer now that we've processed it
connectionRecord.pendingData = [];
connectionRecord.pendingDataSize = 0;
// Add the renegotiation listener for SNI validation
if (serverName) {
socket.on('data', (renegChunk: Buffer) => {
if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) {
try {
// Try to extract SNI from potential renegotiation
const newSNI = extractSNI(renegChunk);
if (newSNI && newSNI !== connectionRecord.lockedDomain) {
console.log(`[${connectionId}] Rehandshake detected with different SNI: ${newSNI} vs locked ${connectionRecord.lockedDomain}. Terminating connection.`);
initiateCleanupOnce('sni_mismatch');
} else if (newSNI && this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Rehandshake detected with same SNI: ${newSNI}. Allowing.`);
}
} catch (err) {
console.log(`[${connectionId}] Error processing potential renegotiation: ${err}. Allowing connection to continue.`);
}
}
});
}
// Set protocol-specific timeout based on detected protocol
if (connectionRecord.cleanupTimer) {
clearTimeout(connectionRecord.cleanupTimer);
}
// Set timeout based on protocol
const protocolTimeout = this.getProtocolTimeout(connectionRecord, domainConfig);
connectionRecord.cleanupTimer = setTimeout(() => {
console.log(`[${connectionId}] ${connectionRecord.protocolType} connection exceeded max lifetime (${plugins.prettyMs(protocolTimeout)}), forcing cleanup.`);
initiateCleanupOnce(`${connectionRecord.protocolType}_max_lifetime`);
}, protocolTimeout);
});
};
// --- PORT RANGE-BASED HANDLING ---
// Only apply port-based rules if the incoming port is within one of the global port ranges.
if (this.settings.globalPortRanges && isPortInRanges(localPort, this.settings.globalPortRanges)) {
if (this.settings.forwardAllGlobalRanges) {
if (this.settings.defaultAllowedIPs && !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
console.log(`[${connectionId}] Connection from ${remoteIP} rejected: IP ${remoteIP} not allowed in global default allowed list.`);
socket.end();
return;
}
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Port-based connection from ${remoteIP} on port ${localPort} forwarded to global target IP ${this.settings.targetIP}.`);
}
setupConnection('', undefined, {
domains: ['global'],
allowedIPs: this.settings.defaultAllowedIPs || [],
blockedIPs: this.settings.defaultBlockedIPs || [],
targetIPs: [this.settings.targetIP!],
portRanges: []
}, localPort);
return;
} else {
// Attempt to find a matching forced domain config based on the local port.
const forcedDomain = this.settings.domainConfigs.find(
domain => domain.portRanges && domain.portRanges.length > 0 && isPortInRanges(localPort, domain.portRanges)
);
if (forcedDomain) {
const effectiveAllowedIPs: string[] = [
...forcedDomain.allowedIPs,
...(this.settings.defaultAllowedIPs || [])
];
const effectiveBlockedIPs: string[] = [
...(forcedDomain.blockedIPs || []),
...(this.settings.defaultBlockedIPs || [])
];
if (!isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) {
console.log(`[${connectionId}] Connection from ${remoteIP} rejected: IP not allowed for domain ${forcedDomain.domains.join(', ')} on port ${localPort}.`);
socket.end();
return;
}
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Port-based connection from ${remoteIP} on port ${localPort} matched domain ${forcedDomain.domains.join(', ')}.`);
}
setupConnection('', undefined, forcedDomain, localPort);
return;
}
// Fall through to SNI/default handling if no forced domain config is found.
}
}
// --- FALLBACK: SNI-BASED HANDLING (or default when SNI is disabled) ---
if (this.settings.sniEnabled) {
initialDataReceived = false;
socket.once('data', (chunk: Buffer) => {
if (initialTimeout) {
clearTimeout(initialTimeout);
initialTimeout = null;
}
initialDataReceived = true;
const serverName = extractSNI(chunk) || '';
// Lock the connection to the negotiated SNI.
connectionRecord.lockedDomain = serverName;
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Received connection from ${remoteIP} with SNI: ${serverName || '(empty)'}`);
}
setupConnection(serverName, chunk);
});
} else {
initialDataReceived = true;
if (!this.settings.defaultAllowedIPs || this.settings.defaultAllowedIPs.length === 0 || !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for non-SNI connection`);
}
setupConnection('');
}
};
// --- SETUP LISTENERS ---
// Determine which ports to listen on.
const listeningPorts = new Set<number>();
if (this.settings.globalPortRanges && this.settings.globalPortRanges.length > 0) {
// Listen on every port defined by the global ranges.
for (const range of this.settings.globalPortRanges) {
for (let port = range.from; port <= range.to; port++) {
listeningPorts.add(port);
}
}
// Also ensure the default fromPort is listened to if it isn't already in the ranges.
listeningPorts.add(this.settings.fromPort);
} else {
listeningPorts.add(this.settings.fromPort);
}
// Create a server for each port.
for (const port of listeningPorts) {
const server = plugins.net
.createServer(connectionHandler)
.on('error', (err: Error) => {
console.log(`Server Error on port ${port}: ${err.message}`);
});
server.listen(port, () => {
console.log(`PortProxy -> OK: Now listening on port ${port}${this.settings.sniEnabled ? ' (SNI passthrough enabled)' : ''}`);
});
this.netServers.push(server);
}
// Log active connection count, longest running durations, and run parity checks periodically
this.connectionLogger = setInterval(() => {
// Immediately return if shutting down
if (this.isShuttingDown) return;
const now = Date.now();
let maxIncoming = 0;
let maxOutgoing = 0;
let httpConnections = 0;
let wsConnections = 0;
let tlsConnections = 0;
let unknownConnections = 0;
let pooledConnections = 0;
// Create a copy of the keys to avoid modification during iteration
const connectionIds = [...this.connectionRecords.keys()];
for (const id of connectionIds) {
const record = this.connectionRecords.get(id);
if (!record) continue;
// Track connection stats by protocol
switch (record.protocolType) {
case 'http': httpConnections++; break;
case 'websocket': wsConnections++; break;
case 'tls':
case 'https': tlsConnections++; break;
default: unknownConnections++; break;
}
if (record.isPooledConnection) {
pooledConnections++;
}
maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime);
if (record.outgoingStartTime) {
maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime);
}
// Parity check: if outgoing socket closed and incoming remains active
if (record.outgoingClosedTime &&
!record.incoming.destroyed &&
!record.connectionClosed &&
(now - record.outgoingClosedTime > 120000)) {
const remoteIP = record.remoteIP;
console.log(`[${id}] Parity check: Incoming socket for ${remoteIP} still active ${plugins.prettyMs(now - record.outgoingClosedTime)} after outgoing closed.`);
this.cleanupConnection(record, 'parity_check');
}
// Skip inactivity check if disabled
if (!this.settings.disableInactivityCheck) {
// Inactivity check - use protocol-specific values
let inactivityThreshold = Math.floor(Math.random() * (1800000 - 1200000 + 1)) + 1200000; // random between 20 and 30 minutes
// Set protocol-specific inactivity thresholds
if (record.protocolType === 'http' && record.isPooledConnection) {
inactivityThreshold = this.settings.httpKeepAliveTimeout || 1200000; // 20 minutes for pooled HTTP
} else if (record.protocolType === 'websocket') {
inactivityThreshold = this.settings.wsConnectionTimeout || 14400000; // 4 hours for WebSocket
} else if (record.protocolType === 'http') {
inactivityThreshold = this.settings.httpConnectionTimeout || 1800000; // 30 minutes for HTTP
}
const inactivityTime = now - record.lastActivity;
if (inactivityTime > inactivityThreshold && !record.connectionClosed) {
console.log(`[${id}] Inactivity check: No activity on ${record.protocolType} connection from ${record.remoteIP} for ${plugins.prettyMs(inactivityTime)}.`);
this.cleanupConnection(record, 'inactivity');
}
}
}
// Log detailed stats periodically
console.log(
`Active connections: ${this.connectionRecords.size}. ` +
`Types: HTTP=${httpConnections}, WS=${wsConnections}, TLS=${tlsConnections}, Unknown=${unknownConnections}, Pooled=${pooledConnections}. ` +
`Longest running: IN=${plugins.prettyMs(maxIncoming)}, OUT=${plugins.prettyMs(maxOutgoing)}. ` +
`Termination stats: ${JSON.stringify({IN: this.terminationStats.incoming, OUT: this.terminationStats.outgoing})}`
);
}, this.settings.inactivityCheckInterval || 30000);
// Make sure the interval doesn't keep the process alive
if (this.connectionLogger.unref) {
this.connectionLogger.unref();
}
2022-07-29 00:49:46 +02:00
}
/**
* Gracefully shut down the proxy
*/
2022-07-29 00:49:46 +02:00
public async stop() {
console.log("PortProxy shutting down...");
this.isShuttingDown = true;
// Stop accepting new connections
const closeServerPromises: Promise<void>[] = this.netServers.map(
server =>
new Promise<void>((resolve) => {
if (!server.listening) {
resolve();
return;
}
server.close((err) => {
if (err) {
console.log(`Error closing server: ${err.message}`);
}
resolve();
});
})
);
// Stop the connection logger
if (this.connectionLogger) {
clearInterval(this.connectionLogger);
this.connectionLogger = null;
}
// Wait for servers to close
await Promise.all(closeServerPromises);
console.log("All servers closed. Cleaning up active connections...");
// Force destroy all active connections immediately
const connectionIds = [...this.connectionRecords.keys()];
console.log(`Cleaning up ${connectionIds.length} active connections...`);
// First pass: End all connections gracefully
for (const id of connectionIds) {
const record = this.connectionRecords.get(id);
if (record) {
try {
// Clear any timers
if (record.cleanupTimer) {
clearTimeout(record.cleanupTimer);
record.cleanupTimer = undefined;
}
// End sockets gracefully
if (record.incoming && !record.incoming.destroyed) {
record.incoming.end();
}
if (record.outgoing && !record.outgoing.destroyed) {
record.outgoing.end();
}
} catch (err) {
console.log(`Error during graceful connection end for ${id}: ${err}`);
}
}
}
// Short delay to allow graceful ends to process
await new Promise(resolve => setTimeout(resolve, 100));
// Second pass: Force destroy everything
for (const id of connectionIds) {
const record = this.connectionRecords.get(id);
if (record) {
try {
// Remove all listeners to prevent memory leaks
if (record.incoming) {
record.incoming.removeAllListeners();
if (!record.incoming.destroyed) {
record.incoming.destroy();
}
}
if (record.outgoing) {
record.outgoing.removeAllListeners();
if (!record.outgoing.destroyed) {
record.outgoing.destroy();
}
}
} catch (err) {
console.log(`Error during forced connection destruction for ${id}: ${err}`);
}
}
}
// Clear all tracking maps
this.connectionRecords.clear();
this.domainTargetIndices.clear();
this.connectionsByIP.clear();
this.connectionRateByIP.clear();
this.netServers = [];
// Reset termination stats
this.terminationStats = {
incoming: {},
outgoing: {}
};
console.log("PortProxy shutdown complete.");
2022-07-29 00:49:46 +02:00
}
}