UNPKG

@push.rocks/smartproxy

Version:

A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.

970 lines 181 kB
import * as plugins from './plugins.js'; import { NetworkProxy } from './classes.networkproxy.js'; import { SniHandler } from './classes.snihandler.js'; // 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, ranges) => { 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, patterns) => { if (!ip || !patterns || patterns.length === 0) return false; const normalizeIP = (ip) => { 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, allowed, blocked = []) => { 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 = () => { 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) => { 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, variationPercent = 5) => { const safeBaseTimeout = ensureSafeTimeout(baseTimeout); const variation = safeBaseTimeout * (variationPercent / 100); return ensureSafeTimeout(safeBaseTimeout + Math.floor(Math.random() * variation * 2) - variation); }; export class PortProxy { constructor(settingsArg) { this.netServers = []; this.connectionRecords = new Map(); this.connectionLogger = null; this.isShuttingDown = false; // Map to track round robin indices for each domain config this.domainTargetIndices = new Map(); // Enhanced stats tracking this.terminationStats = { incoming: {}, outgoing: {}, }; // Connection tracking by IP for rate limiting this.connectionsByIP = new Map(); this.connectionRateByIP = new Map(); // NetworkProxy instance for TLS termination this.networkProxy = null; // Set reasonable defaults for all settings this.settings = { ...settingsArg, targetIP: settingsArg.targetIP || 'localhost', // Timeout settings with reasonable defaults initialDataTimeout: settingsArg.initialDataTimeout || 120000, // 120 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, allowSessionTicket: settingsArg.allowSessionTicket !== undefined ? settingsArg.allowSessionTicket : true, // 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 */ async initializeNetworkProxy() { if (!this.networkProxy) { // Configure NetworkProxy options based on PortProxy settings const networkProxyOptions = { 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 */ async updateDomainConfigs(newDomainConfigs) { 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 */ async updateAcmeSettings(acmeSettings) { 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 */ async syncDomainConfigsToNetworkProxy() { 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 */ async requestCertificate(domain) { 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) */ forwardToNetworkProxy(connectionId, socket, record, initialData, customProxyPort) { // 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 */ setupDirectConnection(connectionId, socket, record, domainConfig, serverName, initialChunk, overridePort) { // Existing connection setup logic const targetHost = domainConfig ? this.getTargetIP(domainConfig) : this.settings.targetIP; const connectionOptions = { 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 = []; 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) => { // 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.setKeepAliveProbes(10); } if ('setKeepAliveInterval' in targetSocket) { targetSocket.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.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) => { 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(); // Set up piping immediately - don't delay this crucial step pipingEstablished = true; // Flush all pending data to target if (record.pendingData.length > 0) { const combinedData = Buffer.concat(record.pendingData); if (this.settings.enableDetailedLogging) { console.log(`[${connectionId}] Forwarding ${combinedData.length} bytes of initial data to target`); } // Write pending data immediately targetSocket.write(combinedData, (err) => { if (err) { console.log(`[${connectionId}] Error writing pending data to target: ${err.message}`); return this.initiateCleanupOnce(record, 'write_error'); } }); // Clear the buffer now that we've processed it record.pendingData = []; record.pendingDataSize = 0; } // Setup piping in both directions without any delays socket.pipe(targetSocket); targetSocket.pipe(socket); // Resume the socket to ensure data flows - CRITICAL! 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(', ')})` : ''}`); } // 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) => { // 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, }; // Check for session tickets if allowSessionTicket is disabled if (this.settings.allowSessionTicket === false) { // Analyze for session resumption attempt (session ticket or PSK) const resumptionInfo = SniHandler.hasSessionResumption(renegChunk, this.settings.enableTlsDebugLogging); if (resumptionInfo.isResumption) { // Always log resumption attempt for easier debugging // Try to extract SNI for logging const extractedSNI = SniHandler.extractSNI(renegChunk, this.settings.enableTlsDebugLogging); console.log(`[${connectionId}] Session resumption detected in renegotiation. ` + `Has SNI: ${resumptionInfo.hasSNI ? 'Yes' : 'No'}, ` + `SNI value: ${extractedSNI || 'None'}, ` + `allowSessionTicket: ${this.settings.allowSessionTicket}`); // Block if there's session resumption without SNI if (!resumptionInfo.hasSNI) { console.log(`[${connectionId}] Session resumption detected in renegotiation without SNI and allowSessionTicket=false. ` + `Terminating connection to force new TLS handshake.`); this.initiateCleanupOnce(record, 'session_ticket_blocked'); return; } else { if (this.settings.enableDetailedLogging) { console.log(`[${connectionId}] Session resumption with SNI detected in renegotiation. ` + `Allowing connection since SNI is present.`); } } } } 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}`); if (this.settings.allowSessionTicket === false) { console.log(`[${connectionId}] Session ticket usage is disabled. Connection will be reset on reconnection attempts.`); } } } // 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 */ getConnectionCountByIP(ip) { return this.connectionsByIP.get(ip)?.size || 0; } /** * Check and update connection rate for an IP */ checkConnectionRate(ip) { 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 */ trackConnectionByIP(ip, connectionId) { if (!this.connectionsByIP.has(ip)) { this.connectionsByIP.set(ip, new Set()); } this.connectionsByIP.get(ip).add(connectionId); } /** * Remove connection tracking for an IP */ removeConnectionByIP(ip, connectionId) { 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 */ incrementTerminationStat(side, reason) { 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) */ cleanupConnection(record, reason = 'normal') { 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 */ updateActivity(record) { record.lastActivity = Date.now(); // Clear any inactivity warning if (record.inactivityWarningIssued) { record.inactivityWarningIssued = false; } } /** * Get target IP with round-robin support */ getTargetIP(domainConfig) { 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 */ initiateCleanupOnce(record, reason = 'normal') { 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 */ handleError(side, record) { return (err) => { const code = err.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.incrementTerm