import * as plugins from './plugins.js'; import { NetworkProxy } from './classes.networkproxy.js'; import { SniHandler } from './classes.snihandler.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 // Allow domain-specific timeout override connectionTimeout?: number; // Connection timeout override (ms) // NetworkProxy integration options for this specific domain useNetworkProxy?: boolean; // Whether to use NetworkProxy for this domain networkProxyPort?: number; // Override default NetworkProxy port for this domain } /** 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; // Timeout settings initialDataTimeout?: number; // Timeout for initial data/SNI (ms), default: 60000 (60s) socketTimeout?: number; // Socket inactivity timeout (ms), default: 3600000 (1h) inactivityCheckInterval?: number; // How often to check for inactive connections (ms), default: 60000 (60s) maxConnectionLifetime?: number; // Default max connection lifetime (ms), default: 86400000 (24h) inactivityTimeout?: number; // Inactivity timeout (ms), default: 14400000 (4h) 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 // Enhanced features disableInactivityCheck?: boolean; // Disable inactivity checking entirely enableKeepAliveProbes?: boolean; // Enable TCP keep-alive probes enableDetailedLogging?: boolean; // Enable detailed connection logging enableTlsDebugLogging?: boolean; // Enable TLS handshake debug logging enableRandomizedTimeouts?: boolean; // Randomize timeouts slightly to prevent thundering herd // 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 keep-alive settings keepAliveTreatment?: 'standard' | 'extended' | 'immortal'; // How to treat keep-alive connections keepAliveInactivityMultiplier?: number; // Multiplier for inactivity timeout for keep-alive connections extendedKeepAliveLifetime?: number; // Extended lifetime for keep-alive connections (ms) // NetworkProxy integration useNetworkProxy?: number[]; // Array of ports to forward to NetworkProxy networkProxyPort?: number; // Port where NetworkProxy is listening (default: 8443) // ACME certificate management options acme?: { enabled?: boolean; // Whether to enable automatic certificate management port?: number; // Port to listen on for ACME challenges (default: 80) contactEmail?: string; // Email for Let's Encrypt account useProduction?: boolean; // Whether to use Let's Encrypt production (default: false for staging) renewThresholdDays?: number; // Days before expiry to renew certificates (default: 30) autoRenew?: boolean; // Whether to automatically renew certificates (default: true) certificateStore?: string; // Directory to store certificates (default: ./certs) skipConfiguredCerts?: boolean; // Skip domains that already have certificates configured }; } /** * Enhanced connection record */ 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 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) isTLS: boolean; // Whether this connection is a TLS connection tlsHandshakeComplete: boolean; // Whether the TLS handshake is complete hasReceivedInitialData: boolean; // Whether initial data has been received domainConfig?: IDomainConfig; // Associated domain config for this connection // Keep-alive tracking hasKeepAlive: boolean; // Whether keep-alive is enabled for this connection inactivityWarningIssued?: boolean; // Whether an inactivity warning has been issued incomingTerminationReason?: string | null; // Reason for incoming termination outgoingTerminationReason?: string | null; // Reason for outgoing termination // NetworkProxy tracking usingNetworkProxy?: boolean; // Whether this connection is using a NetworkProxy // Renegotiation handler renegotiationHandler?: (chunk: Buffer) => void; // Handler for renegotiation detection // Browser connection tracking isBrowserConnection?: boolean; // Whether this connection appears to be from a browser domainSwitches?: number; // Number of times the domain has been switched on this connection } // SNI functions are now imported from SniHandler class // No need for wrapper functions // 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 => { if (!ip || !patterns || patterns.length === 0) return false; const normalizeIP = (ip: string): string[] => { if (!ip) return []; 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); if (normalizedIPVariants.length === 0) return false; 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 (!ip) return false; 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); }; // SNI functions are now imported from SniHandler class // Helper: Ensure timeout values don't exceed Node.js max safe integer const ensureSafeTimeout = (timeout: number): number => { const MAX_SAFE_TIMEOUT = 2147483647; // Maximum safe value (2^31 - 1) return Math.min(Math.floor(timeout), MAX_SAFE_TIMEOUT); }; // Helper: Generate a slightly randomized timeout to prevent thundering herd const randomizeTimeout = (baseTimeout: number, variationPercent: number = 5): number => { const safeBaseTimeout = ensureSafeTimeout(baseTimeout); const variation = safeBaseTimeout * (variationPercent / 100); return ensureSafeTimeout(safeBaseTimeout + Math.floor(Math.random() * variation * 2) - variation); }; 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(); // NetworkProxy instance for TLS termination private networkProxy: NetworkProxy | null = null; constructor(settingsArg: IPortProxySettings) { // Set reasonable defaults for all settings this.settings = { ...settingsArg, targetIP: settingsArg.targetIP || 'localhost', // Timeout settings with reasonable defaults initialDataTimeout: settingsArg.initialDataTimeout || 60000, // 60 seconds for initial handshake socketTimeout: ensureSafeTimeout(settingsArg.socketTimeout || 3600000), // 1 hour socket timeout inactivityCheckInterval: settingsArg.inactivityCheckInterval || 60000, // 60 seconds interval maxConnectionLifetime: ensureSafeTimeout(settingsArg.maxConnectionLifetime || 86400000), // 24 hours default inactivityTimeout: ensureSafeTimeout(settingsArg.inactivityTimeout || 14400000), // 4 hours inactivity timeout 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 || 10000, // 10 seconds maxPendingDataSize: settingsArg.maxPendingDataSize || 10 * 1024 * 1024, // 10MB // Feature flags disableInactivityCheck: settingsArg.disableInactivityCheck || false, enableKeepAliveProbes: settingsArg.enableKeepAliveProbes !== undefined ? settingsArg.enableKeepAliveProbes : true, enableDetailedLogging: settingsArg.enableDetailedLogging || false, enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false, enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false, // Rate limiting defaults maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100, connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300, // Enhanced keep-alive settings keepAliveTreatment: settingsArg.keepAliveTreatment || 'extended', keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6, extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000, // 7 days // NetworkProxy settings networkProxyPort: settingsArg.networkProxyPort || 8443, // Default NetworkProxy port // ACME certificate settings with reasonable defaults acme: settingsArg.acme || { enabled: false, port: 80, contactEmail: 'admin@example.com', useProduction: false, renewThresholdDays: 30, autoRenew: true, certificateStore: './certs', skipConfiguredCerts: false } }; // Initialize NetworkProxy if enabled if (this.settings.useNetworkProxy && this.settings.useNetworkProxy.length > 0) { this.initializeNetworkProxy(); } } /** * Initialize NetworkProxy instance */ private async initializeNetworkProxy(): Promise { if (!this.networkProxy) { // Configure NetworkProxy options based on PortProxy settings const networkProxyOptions: any = { port: this.settings.networkProxyPort!, portProxyIntegration: true, logLevel: this.settings.enableDetailedLogging ? 'debug' : 'info' }; // Add ACME settings if configured if (this.settings.acme) { networkProxyOptions.acme = { ...this.settings.acme }; } this.networkProxy = new NetworkProxy(networkProxyOptions); console.log(`Initialized NetworkProxy on port ${this.settings.networkProxyPort}`); // Convert and apply domain configurations to NetworkProxy await this.syncDomainConfigsToNetworkProxy(); } } /** * Updates the domain configurations for the proxy * @param newDomainConfigs The new domain configurations */ public async updateDomainConfigs(newDomainConfigs: IDomainConfig[]): Promise { console.log(`Updating domain configurations (${newDomainConfigs.length} configs)`); this.settings.domainConfigs = newDomainConfigs; // If NetworkProxy is initialized, resync the configurations if (this.networkProxy) { await this.syncDomainConfigsToNetworkProxy(); } } /** * Updates the ACME certificate settings * @param acmeSettings New ACME settings */ public async updateAcmeSettings(acmeSettings: IPortProxySettings['acme']): Promise { console.log('Updating ACME certificate settings'); // Update settings this.settings.acme = { ...this.settings.acme, ...acmeSettings }; // If NetworkProxy is initialized, update its ACME settings if (this.networkProxy) { try { // Recreate NetworkProxy with new settings if ACME enabled state has changed if (this.settings.acme.enabled !== acmeSettings.enabled) { console.log(`ACME enabled state changed to: ${acmeSettings.enabled}`); // Stop the current NetworkProxy await this.networkProxy.stop(); this.networkProxy = null; // Reinitialize with new settings await this.initializeNetworkProxy(); // Use start() to make sure ACME gets initialized if newly enabled await this.networkProxy.start(); } else { // Update existing NetworkProxy with new settings // Note: Some settings may require a restart to take effect console.log('Updating ACME settings in NetworkProxy'); // For certificate renewals, we might want to trigger checks with the new settings if (acmeSettings.renewThresholdDays) { console.log(`Setting new renewal threshold to ${acmeSettings.renewThresholdDays} days`); // This is implementation-dependent but gives an example if (this.networkProxy.options.acme) { this.networkProxy.options.acme.renewThresholdDays = acmeSettings.renewThresholdDays; } } } } catch (err) { console.log(`Error updating ACME settings: ${err}`); } } } /** * Synchronizes PortProxy domain configurations to NetworkProxy * This allows domains configured in PortProxy to be used by NetworkProxy */ private async syncDomainConfigsToNetworkProxy(): Promise { if (!this.networkProxy) { console.log('Cannot sync configurations - NetworkProxy not initialized'); return; } try { // Get SSL certificates from assets // Import fs directly since it's not in plugins const fs = await import('fs'); let certPair; try { certPair = { key: fs.readFileSync('assets/certs/key.pem', 'utf8'), cert: fs.readFileSync('assets/certs/cert.pem', 'utf8') }; } catch (certError) { console.log(`Warning: Could not read default certificates: ${certError}`); console.log('Using empty certificate placeholders - ACME will generate proper certificates if enabled'); // Use empty placeholders - NetworkProxy will use its internal defaults // or ACME will generate proper ones if enabled certPair = { key: '', cert: '' }; } // Convert domain configs to NetworkProxy configs const proxyConfigs = this.networkProxy.convertPortProxyConfigs( this.settings.domainConfigs, certPair ); // Log ACME-eligible domains if ACME is enabled if (this.settings.acme?.enabled) { const acmeEligibleDomains = proxyConfigs .filter(config => !config.hostName.includes('*')) // Exclude wildcards .map(config => config.hostName); if (acmeEligibleDomains.length > 0) { console.log(`Domains eligible for ACME certificates: ${acmeEligibleDomains.join(', ')}`); } else { console.log('No domains eligible for ACME certificates found in configuration'); } } // Update NetworkProxy with the converted configs this.networkProxy.updateProxyConfigs(proxyConfigs).then(() => { console.log(`Successfully synchronized ${proxyConfigs.length} domain configurations to NetworkProxy`); }).catch(err => { console.log(`Error synchronizing configurations: ${err.message}`); }); } catch (err) { console.log(`Failed to sync configurations: ${err}`); } } /** * Requests a certificate for a specific domain * @param domain The domain to request a certificate for * @returns Promise that resolves to true if the request was successful, false otherwise */ public async requestCertificate(domain: string): Promise { if (!this.networkProxy) { console.log('Cannot request certificate - NetworkProxy not initialized'); return false; } if (!this.settings.acme?.enabled) { console.log('Cannot request certificate - ACME is not enabled'); return false; } try { const result = await this.networkProxy.requestCertificate(domain); if (result) { console.log(`Certificate request for ${domain} submitted successfully`); } else { console.log(`Certificate request for ${domain} failed`); } return result; } catch (err) { console.log(`Error requesting certificate: ${err}`); return false; } } /** * Forwards a TLS connection to a NetworkProxy for handling * @param connectionId - Unique connection identifier * @param socket - The incoming client socket * @param record - The connection record * @param initialData - Initial data chunk (TLS ClientHello) * @param customProxyPort - Optional custom port for NetworkProxy (for domain-specific settings) */ private forwardToNetworkProxy( connectionId: string, socket: plugins.net.Socket, record: IConnectionRecord, initialData: Buffer, customProxyPort?: number ): void { // Ensure NetworkProxy is initialized if (!this.networkProxy) { console.log( `[${connectionId}] NetworkProxy not initialized. Using fallback direct connection.` ); // Fall back to direct connection return this.setupDirectConnection( connectionId, socket, record, undefined, undefined, initialData ); } // Use the custom port if provided, otherwise use the default NetworkProxy port const proxyPort = customProxyPort || this.networkProxy.getListeningPort(); const proxyHost = 'localhost'; // Assuming NetworkProxy runs locally if (this.settings.enableDetailedLogging) { console.log( `[${connectionId}] Forwarding TLS connection to NetworkProxy at ${proxyHost}:${proxyPort}` ); } // Create a connection to the NetworkProxy const proxySocket = plugins.net.connect({ host: proxyHost, port: proxyPort, }); // Store the outgoing socket in the record record.outgoing = proxySocket; record.outgoingStartTime = Date.now(); record.usingNetworkProxy = true; // Set up error handlers proxySocket.on('error', (err) => { console.log(`[${connectionId}] Error connecting to NetworkProxy: ${err.message}`); this.cleanupConnection(record, 'network_proxy_connect_error'); }); // Handle connection to NetworkProxy proxySocket.on('connect', () => { if (this.settings.enableDetailedLogging) { console.log(`[${connectionId}] Connected to NetworkProxy at ${proxyHost}:${proxyPort}`); } // First send the initial data that contains the TLS ClientHello proxySocket.write(initialData); // Now set up bidirectional piping between client and NetworkProxy socket.pipe(proxySocket); proxySocket.pipe(socket); // Setup cleanup handlers proxySocket.on('close', () => { if (this.settings.enableDetailedLogging) { console.log(`[${connectionId}] NetworkProxy connection closed`); } this.cleanupConnection(record, 'network_proxy_closed'); }); socket.on('close', () => { if (this.settings.enableDetailedLogging) { console.log( `[${connectionId}] Client connection closed after forwarding to NetworkProxy` ); } this.cleanupConnection(record, 'client_closed'); }); // Update activity on data transfer socket.on('data', () => this.updateActivity(record)); proxySocket.on('data', () => this.updateActivity(record)); if (this.settings.enableDetailedLogging) { console.log( `[${connectionId}] TLS connection successfully forwarded to NetworkProxy` ); } }); } /** * Sets up a direct connection to the target (original behavior) * This is used when NetworkProxy isn't configured or as a fallback */ private setupDirectConnection( connectionId: string, socket: plugins.net.Socket, record: IConnectionRecord, domainConfig: IDomainConfig | undefined, serverName?: string, initialChunk?: Buffer, overridePort?: number ): void { // Existing connection setup logic 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 = record.remoteIP.replace('::ffff:', ''); } // Create a safe queue for incoming data using a Buffer array // We'll use this to ensure we don't lose data during handler transitions const dataQueue: Buffer[] = []; let queueSize = 0; let processingQueue = false; let drainPending = false; // Flag to track if we've switched to the final piping mechanism // Once this is true, we no longer buffer data in dataQueue let pipingEstablished = false; // Pause the incoming socket to prevent buffer overflows // This ensures we control the flow of data until piping is set up socket.pause(); // Function to safely process the data queue without losing events const processDataQueue = () => { if (processingQueue || dataQueue.length === 0 || pipingEstablished) return; processingQueue = true; try { // Process all queued chunks with the current active handler while (dataQueue.length > 0) { const chunk = dataQueue.shift()!; queueSize -= chunk.length; // Once piping is established, we shouldn't get here, // but just in case, pass to the outgoing socket directly if (pipingEstablished && record.outgoing) { record.outgoing.write(chunk); continue; } // Track bytes received record.bytesReceived += chunk.length; // Check for TLS handshake if (!record.isTLS && SniHandler.isTlsHandshake(chunk)) { record.isTLS = true; if (this.settings.enableTlsDebugLogging) { console.log( `[${connectionId}] TLS handshake detected in tempDataHandler, ${chunk.length} bytes` ); } } // Check if adding this chunk would exceed the buffer limit const newSize = record.pendingDataSize + chunk.length; if (this.settings.maxPendingDataSize && newSize > this.settings.maxPendingDataSize) { console.log( `[${connectionId}] Buffer limit exceeded for connection from ${record.remoteIP}: ${newSize} bytes > ${this.settings.maxPendingDataSize} bytes` ); socket.end(); // Gracefully close the socket this.initiateCleanupOnce(record, 'buffer_limit_exceeded'); return; } // Buffer the chunk and update the size counter record.pendingData.push(Buffer.from(chunk)); record.pendingDataSize = newSize; this.updateActivity(record); } } finally { processingQueue = false; // If there's a pending drain and we've processed everything, // signal we're ready for more data if we haven't established piping yet if (drainPending && dataQueue.length === 0 && !pipingEstablished) { drainPending = false; socket.resume(); } } }; // Unified data handler that safely queues incoming data const safeDataHandler = (chunk: Buffer) => { // If piping is already established, just let the pipe handle it if (pipingEstablished) return; // Add to our queue for orderly processing dataQueue.push(Buffer.from(chunk)); // Make a copy to be safe queueSize += chunk.length; // If queue is getting large, pause socket until we catch up if (this.settings.maxPendingDataSize && queueSize > this.settings.maxPendingDataSize * 0.8) { socket.pause(); drainPending = true; } // Process the queue processDataQueue(); }; // Add our safe data handler socket.on('data', safeDataHandler); // Add initial chunk to pending data if present if (initialChunk) { record.bytesReceived += initialChunk.length; record.pendingData.push(Buffer.from(initialChunk)); record.pendingDataSize = initialChunk.length; } // Create the target socket but don't set up piping immediately const targetSocket = plugins.net.connect(connectionOptions); record.outgoing = targetSocket; record.outgoingStartTime = Date.now(); // Apply socket optimizations targetSocket.setNoDelay(this.settings.noDelay); // Apply keep-alive settings to the outgoing connection as well if (this.settings.keepAlive) { targetSocket.setKeepAlive(true, this.settings.keepAliveInitialDelay); // Apply enhanced TCP keep-alive options if enabled if (this.settings.enableKeepAliveProbes) { try { if ('setKeepAliveProbes' in targetSocket) { (targetSocket as any).setKeepAliveProbes(10); } if ('setKeepAliveInterval' in targetSocket) { (targetSocket as any).setKeepAliveInterval(1000); } } catch (err) { // Ignore errors - these are optional enhancements if (this.settings.enableDetailedLogging) { console.log( `[${connectionId}] Enhanced TCP keep-alive not supported for outgoing socket: ${err}` ); } } } } // 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', this.handleError('outgoing', record)); if (record.outgoingTerminationReason === null) { record.outgoingTerminationReason = 'connection_failed'; this.incrementTerminationStat('outgoing', 'connection_failed'); } // Clean up the connection this.initiateCleanupOnce(record, `connection_failed_${code}`); }); // Setup close handler targetSocket.on('close', this.handleClose('outgoing', record)); socket.on('close', this.handleClose('incoming', record)); // Handle timeouts with keep-alive awareness socket.on('timeout', () => { // For keep-alive connections, just log a warning instead of closing if (record.hasKeepAlive) { console.log( `[${connectionId}] Timeout event on incoming keep-alive connection from ${ record.remoteIP } after ${plugins.prettyMs( this.settings.socketTimeout || 3600000 )}. Connection preserved.` ); // Don't close the connection - just log return; } // For non-keep-alive connections, proceed with normal cleanup console.log( `[${connectionId}] Timeout on incoming side from ${ record.remoteIP } after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}` ); if (record.incomingTerminationReason === null) { record.incomingTerminationReason = 'timeout'; this.incrementTerminationStat('incoming', 'timeout'); } this.initiateCleanupOnce(record, 'timeout_incoming'); }); targetSocket.on('timeout', () => { // For keep-alive connections, just log a warning instead of closing if (record.hasKeepAlive) { console.log( `[${connectionId}] Timeout event on outgoing keep-alive connection from ${ record.remoteIP } after ${plugins.prettyMs( this.settings.socketTimeout || 3600000 )}. Connection preserved.` ); // Don't close the connection - just log return; } // For non-keep-alive connections, proceed with normal cleanup console.log( `[${connectionId}] Timeout on outgoing side from ${ record.remoteIP } after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}` ); if (record.outgoingTerminationReason === null) { record.outgoingTerminationReason = 'timeout'; this.incrementTerminationStat('outgoing', 'timeout'); } this.initiateCleanupOnce(record, 'timeout_outgoing'); }); // Set appropriate timeouts, or disable for immortal keep-alive connections if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') { // Disable timeouts completely for immortal connections socket.setTimeout(0); targetSocket.setTimeout(0); if (this.settings.enableDetailedLogging) { console.log( `[${connectionId}] Disabled socket timeouts for immortal keep-alive connection` ); } } else { // Set normal timeouts for other connections socket.setTimeout(ensureSafeTimeout(this.settings.socketTimeout || 3600000)); targetSocket.setTimeout(ensureSafeTimeout(this.settings.socketTimeout || 3600000)); } // Track outgoing data for bytes counting targetSocket.on('data', (chunk: Buffer) => { record.bytesSent += chunk.length; this.updateActivity(record); }); // 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', this.handleError('outgoing', record)); // Process any remaining data in the queue before switching to piping processDataQueue(); // Setup function to establish piping - we'll use this after flushing data const setupPiping = () => { // Mark that we're switching to piping mode pipingEstablished = true; // Setup piping in both directions socket.pipe(targetSocket); targetSocket.pipe(socket); // Resume the socket to ensure data flows socket.resume(); // Process any data that might be queued in the interim if (dataQueue.length > 0) { // Write any remaining queued data directly to the target socket for (const chunk of dataQueue) { targetSocket.write(chunk); } // Clear the queue dataQueue.length = 0; queueSize = 0; } if (this.settings.enableDetailedLogging) { console.log( `[${connectionId}] Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` + `${ serverName ? ` (SNI: ${serverName})` : domainConfig ? ` (Port-based for domain: ${domainConfig.domains.join(', ')})` : '' }` + ` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${ record.hasKeepAlive ? 'Yes' : 'No' }` ); } else { console.log( `Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` + `${ serverName ? ` (SNI: ${serverName})` : domainConfig ? ` (Port-based for domain: ${domainConfig.domains.join(', ')})` : '' }` ); } }; // Flush all pending data to target if (record.pendingData.length > 0) { const combinedData = Buffer.concat(record.pendingData); targetSocket.write(combinedData, (err) => { if (err) { console.log(`[${connectionId}] Error writing pending data to target: ${err.message}`); return this.initiateCleanupOnce(record, 'write_error'); } // Establish piping now that we've flushed the buffered data setupPiping(); }); } else { // No pending data, just establish piping immediately setupPiping(); } // Clear the buffer now that we've processed it record.pendingData = []; record.pendingDataSize = 0; // Add the renegotiation handler for SNI validation with strict domain enforcement // This will be called after we've established piping if (serverName) { // Define a handler for checking renegotiation with improved detection const renegotiationHandler = (renegChunk: Buffer) => { // Only process if this looks like a TLS ClientHello if (SniHandler.isClientHello(renegChunk)) { try { // Extract SNI from ClientHello // Create a connection info object for the existing connection const connInfo = { sourceIp: record.remoteIP, sourcePort: record.incoming.remotePort || 0, destIp: record.incoming.localAddress || '', destPort: record.incoming.localPort || 0 }; const newSNI = SniHandler.extractSNIWithResumptionSupport(renegChunk, connInfo, this.settings.enableTlsDebugLogging); // Skip if no SNI was found if (!newSNI) return; // Handle SNI change during renegotiation - always terminate for domain switches if (newSNI !== record.lockedDomain) { // Log and terminate the connection for any SNI change console.log( `[${connectionId}] Renegotiation with different SNI: ${record.lockedDomain} -> ${newSNI}. ` + `Terminating connection - SNI domain switching is not allowed.` ); this.initiateCleanupOnce(record, 'sni_mismatch'); } else if (this.settings.enableDetailedLogging) { console.log( `[${connectionId}] Renegotiation detected with same SNI: ${newSNI}. Allowing.` ); } } catch (err) { console.log( `[${connectionId}] Error processing ClientHello: ${err}. Allowing connection to continue.` ); } } }; // Store the handler in the connection record so we can remove it during cleanup record.renegotiationHandler = renegotiationHandler; // The renegotiation handler is added when piping is established // Making it part of setupPiping ensures proper sequencing of event handlers socket.on('data', renegotiationHandler); if (this.settings.enableDetailedLogging) { console.log(`[${connectionId}] TLS renegotiation handler installed for SNI domain: ${serverName}`); } } // Set connection timeout with simpler logic if (record.cleanupTimer) { clearTimeout(record.cleanupTimer); } // For immortal keep-alive connections, skip setting a timeout completely if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') { if (this.settings.enableDetailedLogging) { console.log( `[${connectionId}] Keep-alive connection with immortal treatment - no max lifetime` ); } // No cleanup timer for immortal connections } // For extended keep-alive connections, use extended timeout else if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') { const extendedTimeout = this.settings.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000; // 7 days const safeTimeout = ensureSafeTimeout(extendedTimeout); record.cleanupTimer = setTimeout(() => { console.log( `[${connectionId}] Keep-alive connection from ${ record.remoteIP } exceeded extended lifetime (${plugins.prettyMs(extendedTimeout)}), forcing cleanup.` ); this.initiateCleanupOnce(record, 'extended_lifetime'); }, safeTimeout); // Make sure timeout doesn't keep the process alive if (record.cleanupTimer.unref) { record.cleanupTimer.unref(); } if (this.settings.enableDetailedLogging) { console.log( `[${connectionId}] Keep-alive connection with extended lifetime of ${plugins.prettyMs( extendedTimeout )}` ); } } // For standard connections, use normal timeout else { // Use domain-specific timeout if available, otherwise use default const connectionTimeout = record.domainConfig?.connectionTimeout || this.settings.maxConnectionLifetime!; const safeTimeout = ensureSafeTimeout(connectionTimeout); record.cleanupTimer = setTimeout(() => { console.log( `[${connectionId}] Connection from ${ record.remoteIP } exceeded max lifetime (${plugins.prettyMs(connectionTimeout)}), forcing cleanup.` ); this.initiateCleanupOnce(record, 'connection_timeout'); }, safeTimeout); // Make sure timeout doesn't keep the process alive if (record.cleanupTimer.unref) { record.cleanupTimer.unref(); } } // Mark TLS handshake as complete for TLS connections if (record.isTLS) { record.tlsHandshakeComplete = true; if (this.settings.enableTlsDebugLogging) { console.log( `[${connectionId}] TLS handshake complete for connection from ${record.remoteIP}` ); } } }); } /** * 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; } /** * 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; // Remove all data handlers (both standard and renegotiation) to make sure we clean up properly if (record.incoming) { try { // Remove our safe data handler record.incoming.removeAllListeners('data'); // Reset the handler references record.renegotiationHandler = undefined; } catch (err) { console.log(`[${record.id}] Error removing data handlers: ${err}`); } } 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}, ` + `TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${ record.hasKeepAlive ? 'Yes' : 'No' }` + `${record.usingNetworkProxy ? ', Using NetworkProxy' : ''}` + `${record.domainSwitches ? `, Domain switches: ${record.domainSwitches}` : ''}` ); } 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(); // Clear any inactivity warning if (record.inactivityWarningIssued) { record.inactivityWarningIssued = false; } } /** * 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!; } /** * Initiates cleanup once for a connection */ private initiateCleanupOnce(record: IConnectionRecord, reason: string = 'normal'): void { if (this.settings.enableDetailedLogging) { console.log(`[${record.id}] Connection cleanup initiated for ${record.remoteIP} (${reason})`); } if ( record.incomingTerminationReason === null || record.incomingTerminationReason === undefined ) { record.incomingTerminationReason = reason; this.incrementTerminationStat('incoming', reason); } this.cleanupConnection(record, reason); } /** * Creates a generic error handler for incoming or outgoing sockets */ private handleError(side: 'incoming' | 'outgoing', record: IConnectionRecord) { return (err: Error) => { const code = (err as any).code; let reason = 'error'; const now = Date.now(); const connectionDuration = now - record.incomingStartTime; const lastActivityAge = now - record.lastActivity; if (code === 'ECONNRESET') { reason = 'econnreset'; console.log( `[${record.id}] ECONNRESET on ${side} side from ${record.remoteIP}: ${ err.message }. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs( lastActivityAge )} ago` ); } else if (code === 'ETIMEDOUT') { reason = 'etimedout'; console.log( `[${record.id}] ETIMEDOUT on ${side} side from ${record.remoteIP}: ${ err.message }. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs( lastActivityAge )} ago` ); } else { console.log( `[${record.id}] Error on ${side} side from ${record.remoteIP}: ${ err.message }. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs( lastActivityAge )} ago` ); } if (side === 'incoming' && record.incomingTerminationReason === null) { record.incomingTerminationReason = reason; this.incrementTerminationStat('incoming', reason); } else if (side === 'outgoing' && record.outgoingTerminationReason === null) { record.outgoingTerminationReason = reason; this.incrementTerminationStat('outgoing', reason); } this.initiateCleanupOnce(record, reason); }; } /** * Creates a generic close handler for incoming or outgoing sockets */ private handleClose(side: 'incoming' | 'outgoing', record: IConnectionRecord) { return () => { if (this.settings.enableDetailedLogging) { console.log(`[${record.id}] Connection closed on ${side} side from ${record.remoteIP}`); } if (side === 'incoming' && record.incomingTerminationReason === null) { record.incomingTerminationReason = 'normal'; this.incrementTerminationStat('incoming', 'normal'); } else if (side === 'outgoing' && record.outgoingTerminationReason === null) { record.outgoingTerminationReason = 'normal'; this.incrementTerminationStat('outgoing', 'normal'); // Record the time when outgoing socket closed. record.outgoingClosedTime = Date.now(); } this.initiateCleanupOnce(record, 'closed_' + side); }; } /** * 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; } // Initialize NetworkProxy if needed (useNetworkProxy is set but networkProxy isn't initialized) if (this.settings.useNetworkProxy && this.settings.useNetworkProxy.length > 0 && !this.networkProxy) { await this.initializeNetworkProxy(); } // Start NetworkProxy if configured if (this.networkProxy) { await this.networkProxy.start(); console.log(`NetworkProxy started on port ${this.settings.networkProxyPort}`); // Log ACME status if (this.settings.acme?.enabled) { console.log(`ACME certificate management is enabled (${this.settings.acme.useProduction ? 'Production' : 'Staging'} mode)`); console.log(`ACME HTTP challenge server on port ${this.settings.acme.port}`); // Register domains for ACME certificates if enabled if (this.networkProxy.options.acme?.enabled) { console.log('Registering domains with ACME certificate manager...'); // The NetworkProxy will handle this internally via registerDomainsWithAcmeManager() } } } // 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); // 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 bytesReceived: 0, bytesSent: 0, remoteIP: remoteIP, localPort: localPort, isTLS: false, tlsHandshakeComplete: false, hasReceivedInitialData: false, hasKeepAlive: false, // Will set to true if keep-alive is applied incomingTerminationReason: null, outgoingTerminationReason: null, // Initialize NetworkProxy tracking usingNetworkProxy: false, // Initialize browser connection tracking isBrowserConnection: false, domainSwitches: 0, }; // Apply keep-alive settings if enabled if (this.settings.keepAlive) { socket.setKeepAlive(true, this.settings.keepAliveInitialDelay); connectionRecord.hasKeepAlive = true; // Mark connection as having keep-alive // Apply enhanced TCP keep-alive options if enabled if (this.settings.enableKeepAliveProbes) { try { // These are platform-specific and may not be available if ('setKeepAliveProbes' in socket) { (socket as any).setKeepAliveProbes(10); // More aggressive probing } if ('setKeepAliveInterval' in socket) { (socket as any).setKeepAliveInterval(1000); // 1 second interval between probes } } catch (err) { // Ignore errors - these are optional enhancements if (this.settings.enableDetailedLogging) { console.log( `[${connectionId}] Enhanced TCP keep-alive settings not supported: ${err}` ); } } } } // 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}. ` + `Keep-Alive: ${connectionRecord.hasKeepAlive ? 'Enabled' : 'Disabled'}. ` + `Active connections: ${this.connectionRecords.size}` ); } else { console.log( `New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionRecords.size}` ); } // Check if this connection should be forwarded directly to NetworkProxy // First check port-based forwarding settings let shouldUseNetworkProxy = this.settings.useNetworkProxy && this.settings.useNetworkProxy.includes(localPort); // We'll look for domain-specific settings after SNI extraction if (shouldUseNetworkProxy) { // For NetworkProxy ports, we want to capture the TLS handshake and forward directly let initialDataReceived = false; // Set an initial timeout for handshake data let initialTimeout: NodeJS.Timeout | null = setTimeout(() => { if (!initialDataReceived) { console.log( `[${connectionId}] Initial data timeout (${this.settings.initialDataTimeout}ms) for connection from ${remoteIP} on port ${localPort}` ); if (connectionRecord.incomingTerminationReason === null) { connectionRecord.incomingTerminationReason = 'initial_timeout'; this.incrementTerminationStat('incoming', 'initial_timeout'); } socket.end(); this.cleanupConnection(connectionRecord, 'initial_timeout'); } }, this.settings.initialDataTimeout!); // Make sure timeout doesn't keep the process alive if (initialTimeout.unref) { initialTimeout.unref(); } socket.on('error', this.handleError('incoming', connectionRecord)); // First data handler to capture initial TLS handshake for NetworkProxy socket.once('data', (chunk: Buffer) => { // Clear the initial timeout since we've received data if (initialTimeout) { clearTimeout(initialTimeout); initialTimeout = null; } initialDataReceived = true; connectionRecord.hasReceivedInitialData = true; // Check if this looks like a TLS handshake if (SniHandler.isTlsHandshake(chunk)) { connectionRecord.isTLS = true; // Try to extract SNI for domain-specific NetworkProxy handling const connInfo = { sourceIp: remoteIP, sourcePort: socket.remotePort || 0, destIp: socket.localAddress || '', destPort: socket.localPort || 0 }; // Extract SNI to check for domain-specific NetworkProxy settings const serverName = SniHandler.processTlsPacket( chunk, connInfo, this.settings.enableTlsDebugLogging ); if (serverName) { // If we got an SNI, check for domain-specific NetworkProxy settings const domainConfig = this.settings.domainConfigs.find((config) => config.domains.some((d) => plugins.minimatch(serverName, d)) ); // Save domain config and SNI in connection record connectionRecord.domainConfig = domainConfig; connectionRecord.lockedDomain = serverName; // Use domain-specific NetworkProxy port if configured if (domainConfig?.useNetworkProxy) { const networkProxyPort = domainConfig.networkProxyPort || this.settings.networkProxyPort; if (this.settings.enableDetailedLogging) { console.log( `[${connectionId}] Using domain-specific NetworkProxy for ${serverName} on port ${networkProxyPort}` ); } // Forward to NetworkProxy with domain-specific port this.forwardToNetworkProxy(connectionId, socket, connectionRecord, chunk, networkProxyPort); return; } } // Forward directly to NetworkProxy without domain-specific settings this.forwardToNetworkProxy(connectionId, socket, connectionRecord, chunk); } else { // If not TLS, use normal direct connection console.log(`[${connectionId}] Non-TLS connection on NetworkProxy port ${localPort}`); this.setupDirectConnection(connectionId, socket, connectionRecord, undefined, undefined, chunk); } }); } else { // For non-NetworkProxy ports, proceed with normal processing // Define helpers for rejecting connections const rejectIncomingConnection = (reason: string, logMessage: string) => { console.log(`[${connectionId}] ${logMessage}`); socket.end(); if (connectionRecord.incomingTerminationReason === null) { connectionRecord.incomingTerminationReason = reason; this.incrementTerminationStat('incoming', reason); } this.cleanupConnection(connectionRecord, reason); }; let initialDataReceived = false; // 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 (connectionRecord.incomingTerminationReason === null) { connectionRecord.incomingTerminationReason = 'initial_timeout'; this.incrementTerminationStat('incoming', 'initial_timeout'); } socket.end(); this.cleanupConnection(connectionRecord, 'initial_timeout'); } }, this.settings.initialDataTimeout!); // Make sure timeout doesn't keep the process alive if (initialTimeout.unref) { initialTimeout.unref(); } } else { initialDataReceived = true; connectionRecord.hasReceivedInitialData = true; } socket.on('error', this.handleError('incoming', connectionRecord)); // Track data for bytes counting socket.on('data', (chunk: Buffer) => { connectionRecord.bytesReceived += chunk.length; this.updateActivity(connectionRecord); // Check for TLS handshake if this is the first chunk if (!connectionRecord.isTLS && SniHandler.isTlsHandshake(chunk)) { connectionRecord.isTLS = true; if (this.settings.enableTlsDebugLogging) { console.log( `[${connectionId}] TLS handshake detected from ${remoteIP}, ${chunk.length} bytes` ); // Try to extract SNI and log detailed debug info // Create connection info for debug logging const debugConnInfo = { sourceIp: remoteIP, sourcePort: socket.remotePort || 0, destIp: socket.localAddress || '', destPort: socket.localPort || 0 }; SniHandler.extractSNIWithResumptionSupport(chunk, debugConnInfo, true); } } }); /** * 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; } // Mark that we've received initial data initialDataReceived = true; connectionRecord.hasReceivedInitialData = true; // Check if this looks like a TLS handshake const isTlsHandshakeDetected = initialChunk && SniHandler.isTlsHandshake(initialChunk); if (isTlsHandshakeDetected) { connectionRecord.isTLS = true; if (this.settings.enableTlsDebugLogging) { console.log( `[${connectionId}] TLS handshake detected in setup, ${initialChunk.length} bytes` ); } } // 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; // Save domain config in connection record connectionRecord.domainConfig = domainConfig; // Check if this domain should use NetworkProxy (domain-specific setting) if (domainConfig?.useNetworkProxy && this.networkProxy) { if (this.settings.enableDetailedLogging) { console.log( `[${connectionId}] Domain ${serverName} is configured to use NetworkProxy` ); } const networkProxyPort = domainConfig.networkProxyPort || this.settings.networkProxyPort; if (initialChunk && connectionRecord.isTLS) { // For TLS connections with initial chunk, forward to NetworkProxy this.forwardToNetworkProxy( connectionId, socket, connectionRecord, initialChunk, networkProxyPort // Pass the domain-specific NetworkProxy port if configured ); return; // Skip normal connection setup } } // 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` ); } } // Save the initial SNI if (serverName) { connectionRecord.lockedDomain = serverName; } // Set up the direct connection return this.setupDirectConnection( connectionId, socket, connectionRecord, domainConfig, serverName, initialChunk, overridePort ); }; // --- 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 && this.settings.defaultAllowedIPs.length > 0 && !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; // Try to extract SNI let serverName = ''; if (SniHandler.isTlsHandshake(chunk)) { connectionRecord.isTLS = true; if (this.settings.enableTlsDebugLogging) { console.log( `[${connectionId}] Extracting SNI from TLS handshake, ${chunk.length} bytes` ); } // Create connection info object for SNI extraction const connInfo = { sourceIp: remoteIP, sourcePort: socket.remotePort || 0, destIp: socket.localAddress || '', destPort: socket.localPort || 0 }; // Use the new processTlsPacket method for comprehensive handling serverName = SniHandler.processTlsPacket( chunk, connInfo, this.settings.enableTlsDebugLogging, connectionRecord.lockedDomain // Pass any previously negotiated domain as a hint ) || ''; } // 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; connectionRecord.hasReceivedInitialData = 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, () => { const isNetworkProxyPort = this.settings.useNetworkProxy?.includes(port); console.log( `PortProxy -> OK: Now listening on port ${port}${ this.settings.sniEnabled && !isNetworkProxyPort ? ' (SNI passthrough enabled)' : '' }${isNetworkProxyPort ? ' (NetworkProxy forwarding 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 tlsConnections = 0; let nonTlsConnections = 0; let completedTlsHandshakes = 0; let pendingTlsHandshakes = 0; let keepAliveConnections = 0; let networkProxyConnections = 0; let domainSwitchedConnections = 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 if (record.isTLS) { tlsConnections++; if (record.tlsHandshakeComplete) { completedTlsHandshakes++; } else { pendingTlsHandshakes++; } } else { nonTlsConnections++; } if (record.hasKeepAlive) { keepAliveConnections++; } if (record.usingNetworkProxy) { networkProxyConnections++; } if (record.domainSwitches && record.domainSwitches > 0) { domainSwitchedConnections++; } 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'); } // Check for stalled connections waiting for initial data if ( !record.hasReceivedInitialData && now - record.incomingStartTime > this.settings.initialDataTimeout! / 2 ) { console.log( `[${id}] Warning: Connection from ${ record.remoteIP } has not received initial data after ${plugins.prettyMs( now - record.incomingStartTime )}` ); } // Skip inactivity check if disabled or for immortal keep-alive connections if ( !this.settings.disableInactivityCheck && !(record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') ) { const inactivityTime = now - record.lastActivity; // Use extended timeout for extended-treatment keep-alive connections let effectiveTimeout = this.settings.inactivityTimeout!; if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') { const multiplier = this.settings.keepAliveInactivityMultiplier || 6; effectiveTimeout = effectiveTimeout * multiplier; } if (inactivityTime > effectiveTimeout && !record.connectionClosed) { // For keep-alive connections, issue a warning first if (record.hasKeepAlive && !record.inactivityWarningIssued) { console.log( `[${id}] Warning: Keep-alive connection from ${ record.remoteIP } inactive for ${plugins.prettyMs(inactivityTime)}. ` + `Will close in 10 minutes if no activity.` ); // Set warning flag and add grace period record.inactivityWarningIssued = true; record.lastActivity = now - (effectiveTimeout - 600000); // Try to stimulate activity with a probe packet if (record.outgoing && !record.outgoing.destroyed) { try { record.outgoing.write(Buffer.alloc(0)); if (this.settings.enableDetailedLogging) { console.log(`[${id}] Sent probe packet to test keep-alive connection`); } } catch (err) { console.log(`[${id}] Error sending probe packet: ${err}`); } } } else { // For non-keep-alive or after warning, close the connection console.log( `[${id}] Inactivity check: No activity on connection from ${record.remoteIP} ` + `for ${plugins.prettyMs(inactivityTime)}.` + (record.hasKeepAlive ? ' Despite keep-alive being enabled.' : '') ); this.cleanupConnection(record, 'inactivity'); } } else if (inactivityTime <= effectiveTimeout && record.inactivityWarningIssued) { // If activity detected after warning, clear the warning if (this.settings.enableDetailedLogging) { console.log( `[${id}] Connection activity detected after inactivity warning, resetting warning` ); } record.inactivityWarningIssued = false; } } } // Log detailed stats periodically console.log( `Active connections: ${this.connectionRecords.size}. ` + `Types: TLS=${tlsConnections} (Completed=${completedTlsHandshakes}, Pending=${pendingTlsHandshakes}), ` + `Non-TLS=${nonTlsConnections}, KeepAlive=${keepAliveConnections}, NetworkProxy=${networkProxyConnections}, ` + `DomainSwitched=${domainSwitchedConnections}. ` + `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 || 60000); // 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}`); } } } // Stop NetworkProxy if it was started (which also stops ACME manager) if (this.networkProxy) { try { console.log('Stopping NetworkProxy...'); await this.networkProxy.stop(); console.log('NetworkProxy stopped successfully'); // Log ACME shutdown if it was enabled if (this.settings.acme?.enabled) { console.log('ACME certificate manager stopped'); } } catch (err) { console.log(`Error stopping NetworkProxy: ${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.'); } }