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; } // 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 }; export class PortProxy { private netServers: plugins.net.Server[] = []; settings: IPortProxySettings; private connectionRecords: Map = new Map(); private connectionLogger: NodeJS.Timeout | null = null; private isShuttingDown: boolean = false; // Map to track round robin indices for each domain config private domainTargetIndices: Map = new Map(); // Enhanced stats tracking private terminationStats: { incoming: Record; outgoing: Record; } = { incoming: {}, outgoing: {}, }; // Connection tracking by IP for rate limiting private connectionsByIP: Map> = new Map(); private connectionRateByIP: Map = 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 }; } /** * 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 */ 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(); 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(); } } /** * Gracefully shut down the proxy */ public async stop() { console.log("PortProxy shutting down..."); this.isShuttingDown = true; // Stop accepting new connections const closeServerPromises: Promise[] = this.netServers.map( server => new Promise((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."); } }