import * as plugins from './plugins.js'; import { NetworkProxy } from './classes.networkproxy.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) // New properties for NetworkProxy integration useNetworkProxy?: boolean; // When true, forwards TLS connections to NetworkProxy networkProxyIndex?: number; // Optional index to specify which NetworkProxy to use (defaults to 0) } /** * Port proxy settings including global allowed port ranges * * NOTE: In version 3.31.0+, timeout settings have been simplified and hardcoded with sensible defaults * to ensure TLS certificate safety in all deployment scenarios, especially chained proxies. */ 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; // Simplified timeout settings gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown // Ranged port settings 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 // Logging settings 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 // NetworkProxy integration networkProxies?: NetworkProxy[]; // Array of NetworkProxy instances to use for TLS termination // New settings for chained proxy configurations and TLS handling isChainedProxy?: boolean; // Whether this proxy is part of a proxy chain (detected automatically if unspecified) chainPosition?: 'first' | 'middle' | 'last'; // Position in the proxy chain (affects TLS handling) aggressiveTlsRefresh?: boolean; // Use more aggressive TLS refresh timeouts (default: true for chained) // TLS session cache configuration tlsSessionCache?: { enabled?: boolean; // Whether to use the TLS session cache (default: true) maxEntries?: number; // Maximum cache entries (default: 10000) expiryTime?: number; // Session expiry time in ms (default: 24h) cleanupInterval?: number; // Cache cleanup interval in ms (default: 10min) }; } /** * 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 incomingKeepAliveEnabled?: boolean; // Whether keep-alive is enabled on incoming socket outgoingKeepAliveEnabled?: boolean; // Whether keep-alive is enabled on outgoing socket inactivityWarningIssued?: boolean; // Whether an inactivity warning has been issued incomingTerminationReason?: string | null; // Reason for incoming termination outgoingTerminationReason?: string | null; // Reason for outgoing termination // New field for NetworkProxy tracking usingNetworkProxy?: boolean; // Whether this connection is using a NetworkProxy networkProxyIndex?: number; // Which NetworkProxy instance is being used // Sleep detection fields possibleSystemSleep?: boolean; // Flag to indicate a possible system sleep was detected lastSleepDetection?: number; // Timestamp of the last sleep detection } /** * Structure to track TLS session information for proper resumption handling */ interface ITlsSessionInfo { domain: string; // The SNI domain associated with this session sessionId?: Buffer; // The TLS session ID (if available) ticketId?: string; // Session ticket identifier for newer TLS versions ticketTimestamp: number; // When this session was recorded lastAccessed?: number; // When this session was last accessed accessCount?: number; // How many times this session has been used } /** * Configuration for TLS session cache */ interface ITlsSessionCacheConfig { maxEntries: number; // Maximum number of entries to keep in the cache expiryTime: number; // Time in ms before sessions expire (default: 24 hours) cleanupInterval: number; // Interval in ms to run cleanup (default: 10 minutes) enabled: boolean; // Whether session caching is enabled } // Default configuration for session cache with relaxed timeouts const DEFAULT_SESSION_CACHE_CONFIG: ITlsSessionCacheConfig = { maxEntries: 20000, // Default max 20,000 entries (doubled) expiryTime: 7 * 24 * 60 * 60 * 1000, // 7 days default (increased from 24 hours) cleanupInterval: 30 * 60 * 1000, // Clean up every 30 minutes (relaxed from 10 minutes) enabled: true // Enabled by default }; // Enhanced TLS session cache with size limits and better performance class TlsSessionCache { private cache = new Map(); private config: ITlsSessionCacheConfig; private cleanupTimer: NodeJS.Timeout | null = null; private lastCleanupTime: number = 0; private cacheStats = { hits: 0, misses: 0, expirations: 0, evictions: 0, total: 0 }; constructor(config?: Partial) { this.config = { ...DEFAULT_SESSION_CACHE_CONFIG, ...config }; this.startCleanupTimer(); } /** * Get a session from the cache */ public get(key: string): ITlsSessionInfo | undefined { // Skip if cache is disabled if (!this.config.enabled) return undefined; const entry = this.cache.get(key); if (entry) { // Update access information entry.lastAccessed = Date.now(); entry.accessCount = (entry.accessCount || 0) + 1; this.cache.set(key, entry); this.cacheStats.hits++; return entry; } this.cacheStats.misses++; return undefined; } /** * Check if the cache has a key */ public has(key: string): boolean { // Skip if cache is disabled if (!this.config.enabled) return false; const exists = this.cache.has(key); if (exists) { const entry = this.cache.get(key)!; // Check if entry has expired if (Date.now() - entry.ticketTimestamp > this.config.expiryTime) { this.cache.delete(key); this.cacheStats.expirations++; return false; } // Update last accessed time entry.lastAccessed = Date.now(); this.cache.set(key, entry); } return exists; } /** * Set a session in the cache */ public set(key: string, value: ITlsSessionInfo): void { // Skip if cache is disabled if (!this.config.enabled) return; // Ensure timestamps are set const entry = { ...value, lastAccessed: Date.now(), accessCount: 0 }; // Check if we need to evict entries if (!this.cache.has(key) && this.cache.size >= this.config.maxEntries) { this.evictOldest(); } this.cache.set(key, entry); this.cacheStats.total = this.cache.size; // Run cleanup if it's been a while const timeSinceCleanup = Date.now() - this.lastCleanupTime; if (timeSinceCleanup > this.config.cleanupInterval * 2) { this.cleanup(); } } /** * Delete a session from the cache */ public delete(key: string): boolean { return this.cache.delete(key); } /** * Clear the entire cache */ public clear(): void { this.cache.clear(); this.cacheStats.total = 0; } /** * Get cache statistics */ public getStats(): any { return { ...this.cacheStats, size: this.cache.size, enabled: this.config.enabled, maxEntries: this.config.maxEntries, expiryTimeHours: this.config.expiryTime / (60 * 60 * 1000) }; } /** * Update cache configuration */ public updateConfig(config: Partial): void { this.config = { ...this.config, ...config }; // Restart the cleanup timer with new interval this.startCleanupTimer(); // Run immediate cleanup if max entries was reduced if (config.maxEntries && this.cache.size > config.maxEntries) { while (this.cache.size > config.maxEntries) { this.evictOldest(); } } } /** * Start the cleanup timer */ private startCleanupTimer(): void { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; } if (!this.config.enabled) return; this.cleanupTimer = setInterval(() => { this.cleanup(); }, this.config.cleanupInterval); // Make sure the interval doesn't keep the process alive if (this.cleanupTimer.unref) { this.cleanupTimer.unref(); } } /** * Clean up expired entries */ private cleanup(): void { this.lastCleanupTime = Date.now(); const now = Date.now(); let expiredCount = 0; for (const [key, info] of this.cache.entries()) { if (now - info.ticketTimestamp > this.config.expiryTime) { this.cache.delete(key); expiredCount++; } } if (expiredCount > 0) { this.cacheStats.expirations += expiredCount; this.cacheStats.total = this.cache.size; console.log(`TLS Session Cache: Cleaned up ${expiredCount} expired entries. ${this.cache.size} entries remaining.`); } } /** * Evict the oldest entries when cache is full */ private evictOldest(): void { if (this.cache.size === 0) return; let oldestKey: string | null = null; let oldestTime = Date.now(); // Strategy: Find least recently accessed entry for (const [key, info] of this.cache.entries()) { const lastAccess = info.lastAccessed || info.ticketTimestamp; if (lastAccess < oldestTime) { oldestTime = lastAccess; oldestKey = key; } } if (oldestKey) { this.cache.delete(oldestKey); this.cacheStats.evictions++; } } /** * Stop cleanup timer (used during shutdown) */ public stop(): void { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; } } } // Create the global session cache const tlsSessionCache = new TlsSessionCache(); // Legacy function for backward compatibility function stopSessionCleanupTimer() { tlsSessionCache.stop(); } /** * Return type for the extractSNIInfo function */ interface ISNIExtractResult { serverName?: string; // The extracted SNI hostname sessionId?: Buffer; // The TLS session ID if present sessionIdKey?: string; // The hex string representation of session ID sessionTicketId?: string; // Session ticket identifier for TLS 1.3+ resumption hasSessionTicket?: boolean; // Whether a session ticket extension was found isResumption: boolean; // Whether this appears to be a session resumption resumedDomain?: string; // The domain associated with the session if resuming partialExtract?: boolean; // Whether this was only a partial extraction (more data needed) recordsExamined?: number; // Number of TLS records examined in the buffer multipleRecords?: boolean; // Whether multiple TLS records were found in the buffer } /** * Extracts the SNI (Server Name Indication) from a TLS ClientHello packet. * Enhanced for robustness and detailed logging. * Also extracts and tracks TLS Session IDs for session resumption handling. * * Improved to handle: * - Multiple TLS records in a single buffer * - Fragmented TLS handshakes across multiple records * - Partial TLS records that may continue in future chunks * * @param buffer - Buffer containing the TLS ClientHello. * @param enableLogging - Whether to enable detailed logging. * @returns An object containing SNI and session information, or undefined if parsing fails. */ function extractSNIInfo(buffer: Buffer, enableLogging: boolean = false): ISNIExtractResult | undefined { try { // Check if buffer is too small for TLS if (buffer.length < 5) { if (enableLogging) console.log('Buffer too small for TLS header'); return { isResumption: false, partialExtract: true // Indicating we need more data }; } // Check first record type (has to be handshake - 22) const recordType = buffer.readUInt8(0); if (recordType !== 22) { if (enableLogging) console.log(`Not a TLS handshake. Record type: ${recordType}`); return undefined; } // Track multiple records and total records examined let recordsExamined = 0; let multipleRecords = false; let currentPosition = 0; let result: ISNIExtractResult | undefined; // Process potentially multiple TLS records in the buffer while (currentPosition + 5 <= buffer.length) { recordsExamined++; // Read record header const currentRecordType = buffer.readUInt8(currentPosition); // Only process handshake records (type 22) if (currentRecordType !== 22) { if (enableLogging) console.log(`Skipping non-handshake record at position ${currentPosition}, type: ${currentRecordType}`); // Move to next potential record if (currentPosition + 5 <= buffer.length) { // Need at least 5 bytes to determine next record length const nextRecordLength = buffer.readUInt16BE(currentPosition + 3); currentPosition += 5 + nextRecordLength; multipleRecords = true; continue; } else { // Not enough data to determine next record break; } } // Check TLS version const majorVersion = buffer.readUInt8(currentPosition + 1); const minorVersion = buffer.readUInt8(currentPosition + 2); if (enableLogging) console.log(`TLS Version: ${majorVersion}.${minorVersion} at position ${currentPosition}`); // Get record length const recordLength = buffer.readUInt16BE(currentPosition + 3); // Check if we have the complete record if (currentPosition + 5 + recordLength > buffer.length) { if (enableLogging) { console.log(`Incomplete TLS record at position ${currentPosition}. Expected: ${currentPosition + 5 + recordLength}, Got: ${buffer.length}`); } // Return partial info and signal that more data is needed return { isResumption: false, partialExtract: true, recordsExamined, multipleRecords }; } // Process this record - extract handshake information const recordResult = extractSNIFromRecord( buffer.slice(currentPosition, currentPosition + 5 + recordLength), enableLogging ); // If we found SNI or session info in this record, store it if (recordResult && (recordResult.serverName || recordResult.isResumption)) { result = recordResult; result.recordsExamined = recordsExamined; result.multipleRecords = multipleRecords; // Once we've found SNI or session resumption info, we can stop processing // But we'll still set the multipleRecords flag to indicate more records exist if (currentPosition + 5 + recordLength < buffer.length) { result.multipleRecords = true; } break; } // Move to the next record currentPosition += 5 + recordLength; // Set the flag if we've processed multiple records if (currentPosition < buffer.length) { multipleRecords = true; } } // If we processed records but didn't find SNI or session info if (recordsExamined > 0 && !result) { return { isResumption: false, recordsExamined, multipleRecords }; } return result; } catch (err) { console.log(`Error extracting SNI: ${err}`); return undefined; } } /** * Extracts SNI information from a single TLS record * This helper function processes a single complete TLS record */ function extractSNIFromRecord(recordBuffer: Buffer, enableLogging: boolean = false): ISNIExtractResult | undefined { try { // Skip the 5-byte TLS record header let offset = 5; // Verify this is a handshake message const handshakeType = recordBuffer.readUInt8(offset); if (handshakeType !== 1) { // 1 = ClientHello if (enableLogging) console.log(`Not a ClientHello. Handshake type: ${handshakeType}`); return undefined; } // Skip the 4-byte handshake header (type + 3 bytes length) offset += 4; // Check if we have at least 38 more bytes for protocol version and random if (offset + 38 > recordBuffer.length) { if (enableLogging) console.log('Buffer too small for handshake header'); return undefined; } // Client version const clientMajorVersion = recordBuffer.readUInt8(offset); const clientMinorVersion = recordBuffer.readUInt8(offset + 1); if (enableLogging) console.log(`Client Version: ${clientMajorVersion}.${clientMinorVersion}`); // Skip version and random (2 + 32 bytes) offset += 2 + 32; // Session ID if (offset + 1 > recordBuffer.length) { if (enableLogging) console.log('Buffer too small for session ID length'); return undefined; } // Extract Session ID for session resumption tracking const sessionIDLength = recordBuffer.readUInt8(offset); if (enableLogging) console.log(`Session ID Length: ${sessionIDLength}`); // If there's a session ID, extract it let sessionId: Buffer | undefined; let sessionIdKey: string | undefined; let isResumption = false; let resumedDomain: string | undefined; if (sessionIDLength > 0) { if (offset + 1 + sessionIDLength > recordBuffer.length) { if (enableLogging) console.log('Buffer too small for session ID data'); return undefined; } sessionId = Buffer.from(recordBuffer.slice(offset + 1, offset + 1 + sessionIDLength)); // Convert sessionId to a string key for our cache sessionIdKey = sessionId.toString('hex'); if (enableLogging) { console.log(`Session ID: ${sessionIdKey}`); } // Check if this is a session resumption attempt if (tlsSessionCache.has(sessionIdKey)) { const cachedInfo = tlsSessionCache.get(sessionIdKey)!; resumedDomain = cachedInfo.domain; isResumption = true; if (enableLogging) { console.log(`TLS Session Resumption detected for domain: ${resumedDomain}`); } } } offset += 1 + sessionIDLength; // Skip session ID length and data // Cipher suites if (offset + 2 > recordBuffer.length) { if (enableLogging) console.log('Buffer too small for cipher suites length'); return undefined; } const cipherSuitesLength = recordBuffer.readUInt16BE(offset); if (enableLogging) console.log(`Cipher Suites Length: ${cipherSuitesLength}`); if (offset + 2 + cipherSuitesLength > recordBuffer.length) { if (enableLogging) console.log('Buffer too small for cipher suites data'); return undefined; } offset += 2 + cipherSuitesLength; // Skip cipher suites length and data // Compression methods if (offset + 1 > recordBuffer.length) { if (enableLogging) console.log('Buffer too small for compression methods length'); return undefined; } const compressionMethodsLength = recordBuffer.readUInt8(offset); if (enableLogging) console.log(`Compression Methods Length: ${compressionMethodsLength}`); if (offset + 1 + compressionMethodsLength > recordBuffer.length) { if (enableLogging) console.log('Buffer too small for compression methods data'); return undefined; } offset += 1 + compressionMethodsLength; // Skip compression methods length and data // Check if we have extensions data if (offset + 2 > recordBuffer.length) { if (enableLogging) console.log('No extensions data found - end of ClientHello'); // Even without SNI, we might be dealing with a session resumption if (isResumption && resumedDomain) { return { serverName: resumedDomain, // Use the domain from previous session sessionId, sessionIdKey, hasSessionTicket: false, isResumption: true, resumedDomain }; } return { isResumption, sessionId, sessionIdKey }; } // Extensions const extensionsLength = recordBuffer.readUInt16BE(offset); if (enableLogging) console.log(`Extensions Length: ${extensionsLength}`); offset += 2; const extensionsEnd = offset + extensionsLength; if (extensionsEnd > recordBuffer.length) { if (enableLogging) { console.log(`Buffer too small for extensions. Expected end: ${extensionsEnd}, Buffer length: ${recordBuffer.length}`); } // Even without complete extensions, we might be dealing with a session resumption if (isResumption && resumedDomain) { return { serverName: resumedDomain, // Use the domain from previous session sessionId, sessionIdKey, hasSessionTicket: false, isResumption: true, resumedDomain }; } return { isResumption, sessionId, sessionIdKey, partialExtract: true // Indicating we have incomplete extensions data }; } // Variables to track session tickets let hasSessionTicket = false; let sessionTicketId: string | undefined; // Parse extensions while (offset + 4 <= extensionsEnd) { const extensionType = recordBuffer.readUInt16BE(offset); const extensionLength = recordBuffer.readUInt16BE(offset + 2); if (enableLogging) { console.log(`Extension Type: 0x${extensionType.toString(16)}, Length: ${extensionLength}`); } if (offset + 4 + extensionLength > recordBuffer.length) { if (enableLogging) { console.log(`Extension data incomplete. Expected: ${offset + 4 + extensionLength}, Got: ${recordBuffer.length}`); } return { isResumption, sessionId, sessionIdKey, hasSessionTicket, partialExtract: true }; } offset += 4; // Check for Session Ticket extension (type 0x0023) if (extensionType === 0x0023 && extensionLength > 0) { hasSessionTicket = true; // Extract a hash of the ticket for tracking if (extensionLength > 16) { // Ensure we have enough bytes to create a meaningful ID const ticketBytes = recordBuffer.slice(offset, offset + Math.min(16, extensionLength)); sessionTicketId = ticketBytes.toString('hex'); if (enableLogging) { console.log(`Session Ticket found, ID: ${sessionTicketId}`); // Check if this is a known session ticket if (tlsSessionCache.has(`ticket:${sessionTicketId}`)) { const cachedInfo = tlsSessionCache.get(`ticket:${sessionTicketId}`); console.log(`TLS Session Ticket Resumption detected for domain: ${cachedInfo?.domain}`); // Set isResumption and resumedDomain if not already set if (!isResumption && !resumedDomain) { isResumption = true; resumedDomain = cachedInfo?.domain; } } } } } // Server Name Indication extension (type 0x0000) if (extensionType === 0x0000) { if (offset + 2 > recordBuffer.length) { if (enableLogging) console.log('Buffer too small for SNI list length'); return { isResumption, sessionId, sessionIdKey, hasSessionTicket, partialExtract: true }; } const sniListLength = recordBuffer.readUInt16BE(offset); if (enableLogging) console.log(`SNI List Length: ${sniListLength}`); offset += 2; const sniListEnd = offset + sniListLength; if (sniListEnd > recordBuffer.length) { if (enableLogging) { console.log(`Buffer too small for SNI list. Expected end: ${sniListEnd}, Buffer length: ${recordBuffer.length}`); } return { isResumption, sessionId, sessionIdKey, hasSessionTicket, partialExtract: true }; } while (offset + 3 < sniListEnd) { const nameType = recordBuffer.readUInt8(offset++); if (offset + 2 > recordBuffer.length) { if (enableLogging) console.log('Buffer too small for SNI name length'); return { isResumption, sessionId, sessionIdKey, hasSessionTicket, partialExtract: true }; } const nameLen = recordBuffer.readUInt16BE(offset); offset += 2; if (enableLogging) console.log(`Name Type: ${nameType}, Name Length: ${nameLen}`); // Only process hostname entries (type 0) if (nameType === 0) { if (offset + nameLen > recordBuffer.length) { if (enableLogging) { console.log(`Buffer too small for hostname. Expected: ${offset + nameLen}, Got: ${recordBuffer.length}`); } return { isResumption, sessionId, sessionIdKey, hasSessionTicket, partialExtract: true }; } const serverName = recordBuffer.toString('utf8', offset, offset + nameLen); if (enableLogging) console.log(`Extracted SNI: ${serverName}`); // Store the session ID to domain mapping for future resumptions if (sessionIdKey && sessionId && serverName) { tlsSessionCache.set(sessionIdKey, { domain: serverName, sessionId: sessionId, ticketTimestamp: Date.now() }); if (enableLogging) { console.log(`Stored session ${sessionIdKey} for domain ${serverName}`); } } // Also store session ticket information if present if (sessionTicketId && serverName) { tlsSessionCache.set(`ticket:${sessionTicketId}`, { domain: serverName, ticketId: sessionTicketId, ticketTimestamp: Date.now() }); if (enableLogging) { console.log(`Stored session ticket ${sessionTicketId} for domain ${serverName}`); } } // Return the complete extraction result return { serverName, sessionId, sessionIdKey, sessionTicketId, isResumption, resumedDomain, hasSessionTicket }; } // Skip this name entry offset += nameLen; } // Finished processing the SNI extension without finding a hostname break; } else { // Skip other extensions offset += extensionLength; } } // We finished processing all extensions without finding SNI if (enableLogging) console.log('No SNI extension found'); // Even without SNI, we might be dealing with a session resumption if (isResumption && resumedDomain) { return { serverName: resumedDomain, // Use the domain from previous session sessionId, sessionIdKey, sessionTicketId, hasSessionTicket, isResumption: true, resumedDomain }; } // Return a basic result with just the session info return { isResumption, sessionId, sessionIdKey, sessionTicketId, hasSessionTicket, resumedDomain }; } catch (err) { console.log(`Error in extractSNIFromRecord: ${err}`); return undefined; } } /** * Legacy wrapper for extractSNIInfo to maintain backward compatibility * @param buffer - Buffer containing the TLS ClientHello * @param enableLogging - Whether to enable detailed logging * @returns The server name if found, otherwise undefined */ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | undefined { const result = extractSNIInfo(buffer, enableLogging); return result?.serverName; } // 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); }; // Helper: Check if a buffer contains a TLS handshake const isTlsHandshake = (buffer: Buffer): boolean => { return buffer.length > 0 && buffer[0] === 22; // ContentType.handshake }; // 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[] = []; // Define the internal settings interface to include all fields, including those removed from the public interface settings: IPortProxySettings & { // Internal fields removed from public interface in 3.31.0+ initialDataTimeout: number; socketTimeout: number; inactivityCheckInterval: number; maxConnectionLifetime: number; inactivityTimeout: number; disableInactivityCheck: boolean; enableKeepAliveProbes: boolean; keepAliveTreatment: 'standard' | 'extended' | 'immortal'; keepAliveInactivityMultiplier: number; extendedKeepAliveLifetime: number; }; 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(); // New property to store NetworkProxy instances private networkProxies: NetworkProxy[] = []; constructor(settingsArg: IPortProxySettings) { // Auto-detect if this is a chained proxy based on targetIP const targetIP = settingsArg.targetIP || 'localhost'; const isChainedProxy = settingsArg.isChainedProxy !== undefined ? settingsArg.isChainedProxy : (targetIP === 'localhost' || targetIP === '127.0.0.1'); // Use more aggressive timeouts for chained proxies const aggressiveTlsRefresh = settingsArg.aggressiveTlsRefresh !== undefined ? settingsArg.aggressiveTlsRefresh : isChainedProxy; // Configure TLS session cache if specified if (settingsArg.tlsSessionCache) { tlsSessionCache.updateConfig({ enabled: settingsArg.tlsSessionCache.enabled, maxEntries: settingsArg.tlsSessionCache.maxEntries, expiryTime: settingsArg.tlsSessionCache.expiryTime, cleanupInterval: settingsArg.tlsSessionCache.cleanupInterval }); console.log(`Configured TLS session cache with custom settings. Current stats: ${JSON.stringify(tlsSessionCache.getStats())}`); } // Determine appropriate timeouts based on proxy chain position // Much more relaxed socket timeouts let socketTimeout = 6 * 60 * 60 * 1000; // 6 hours default for standalone if (isChainedProxy) { // Still adjust based on chain position, but with more relaxed values const chainPosition = settingsArg.chainPosition || 'middle'; // Adjust timeouts based on position in chain, but significantly relaxed switch (chainPosition) { case 'first': // First proxy handling browser connections socketTimeout = 6 * 60 * 60 * 1000; // 6 hours break; case 'middle': // Middle proxies socketTimeout = 5 * 60 * 60 * 1000; // 5 hours break; case 'last': // Last proxy connects to backend socketTimeout = 6 * 60 * 60 * 1000; // 6 hours break; } console.log(`Configured as ${chainPosition} proxy in chain. Using relaxed timeouts for better stability.`); } // Set sensible defaults with significantly relaxed timeouts this.settings = { ...settingsArg, targetIP: targetIP, // Record the chained proxy status for use in other methods isChainedProxy: isChainedProxy, chainPosition: settingsArg.chainPosition || (isChainedProxy ? 'middle' : 'last'), aggressiveTlsRefresh: aggressiveTlsRefresh, // Much more relaxed timeout settings initialDataTimeout: 120000, // 2 minutes for initial handshake (doubled) socketTimeout: socketTimeout, // 5-6 hours based on chain position inactivityCheckInterval: 5 * 60 * 1000, // 5 minutes between checks (relaxed) maxConnectionLifetime: 12 * 60 * 60 * 1000, // 12 hours lifetime inactivityTimeout: 4 * 60 * 60 * 1000, // 4 hours inactivity timeout gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 60000, // 60 seconds // Socket optimization settings noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true, keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true, keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 30000, // 30 seconds (increased) maxPendingDataSize: settingsArg.maxPendingDataSize || 20 * 1024 * 1024, // 20MB to handle large TLS handshakes // Feature flags - simplified with sensible defaults disableInactivityCheck: false, // Still enable inactivity checks enableKeepAliveProbes: true, // Still enable keep-alive probes enableDetailedLogging: settingsArg.enableDetailedLogging || false, enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false, enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false, // Rate limiting defaults maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 200, // 200 connections per IP (doubled) connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 500, // 500 per minute (increased) // Keep-alive settings with much more relaxed defaults keepAliveTreatment: 'extended', // Use extended keep-alive treatment keepAliveInactivityMultiplier: 3, // 3x normal inactivity timeout for longer extension // Much longer keep-alive lifetimes extendedKeepAliveLifetime: isChainedProxy ? 24 * 60 * 60 * 1000 // 24 hours for chained proxies : 48 * 60 * 60 * 1000, // 48 hours for standalone proxies }; // Store NetworkProxy instances if provided this.networkProxies = settingsArg.networkProxies || []; // Log proxy configuration details console.log(`PortProxy initialized with ${isChainedProxy ? 'chained proxy' : 'standalone'} configuration.`); if (isChainedProxy) { console.log(`TLS certificate refresh: ${aggressiveTlsRefresh ? 'Aggressive' : 'Standard'}`); console.log(`Connection lifetime: ${plugins.prettyMs(this.settings.maxConnectionLifetime)}`); console.log(`Inactivity timeout: ${plugins.prettyMs(this.settings.inactivityTimeout)}`); } } /** * 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 domainConfig - The domain configuration * @param initialData - Initial data chunk (TLS ClientHello) * @param serverName - SNI hostname (if available) */ private forwardToNetworkProxy( connectionId: string, socket: plugins.net.Socket, record: IConnectionRecord, domainConfig: IDomainConfig, initialData: Buffer, serverName?: string ): void { // Determine which NetworkProxy to use const proxyIndex = domainConfig.networkProxyIndex !== undefined ? domainConfig.networkProxyIndex : 0; // Validate the NetworkProxy index if (proxyIndex < 0 || proxyIndex >= this.networkProxies.length) { console.log( `[${connectionId}] Invalid NetworkProxy index: ${proxyIndex}. Using fallback direct connection.` ); // Fall back to direct connection return this.setupDirectConnection( connectionId, socket, record, domainConfig, serverName, initialData ); } const networkProxy = this.networkProxies[proxyIndex]; const proxyPort = networkProxy.getListeningPort(); const proxyHost = 'localhost'; // Assuming NetworkProxy runs locally if (this.settings.enableDetailedLogging) { console.log( `[${connectionId}] Forwarding TLS connection to NetworkProxy[${proxyIndex}] at ${proxyHost}:${proxyPort}` ); } // Create a connection to the NetworkProxy with optimized settings for reliability const proxySocket = plugins.net.connect({ host: proxyHost, port: proxyPort, noDelay: true, // Disable Nagle's algorithm for NetworkProxy connections keepAlive: this.settings.keepAlive, // Use the same keepAlive setting as regular connections keepAliveInitialDelay: Math.max(this.settings.keepAliveInitialDelay - 5000, 5000) // Slightly faster }); // Store the outgoing socket in the record record.outgoing = proxySocket; record.outgoingStartTime = Date.now(); record.usingNetworkProxy = true; record.networkProxyIndex = proxyIndex; // Mark keep-alive as enabled on outgoing if requested if (this.settings.keepAlive) { record.outgoingKeepAliveEnabled = true; // Apply enhanced TCP keep-alive options if enabled if (this.settings.enableKeepAliveProbes) { try { if ('setKeepAliveProbes' in proxySocket) { (proxySocket as any).setKeepAliveProbes(10); } if ('setKeepAliveInterval' in proxySocket) { (proxySocket as any).setKeepAliveInterval(800); } console.log(`[${connectionId}] Enhanced TCP keep-alive configured for NetworkProxy connection`); } catch (err) { // Ignore errors - these are optional enhancements if (this.settings.enableDetailedLogging) { console.log(`[${connectionId}] Enhanced keep-alive not supported for NetworkProxy: ${err}`); } } } } // 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'); }); // Special handler for TLS handshake detection with NetworkProxy socket.on('data', (chunk: Buffer) => { // Check for TLS handshake packets (ContentType.handshake) if (chunk.length > 0 && chunk[0] === 22) { console.log(`[${connectionId}] Detected potential TLS handshake with NetworkProxy, updating activity`); this.updateActivity(record); } }); // Update activity on data transfer from the proxy socket proxySocket.on('data', () => this.updateActivity(record)); // Special handling for application-level keep-alives on NetworkProxy connections if (this.settings.keepAlive && record.isTLS) { // Set up a timer to periodically send application-level keep-alives const keepAliveTimer = setInterval(() => { if (proxySocket && !proxySocket.destroyed && record && !record.connectionClosed) { try { // Send 0-byte packet as application-level keep-alive proxySocket.write(Buffer.alloc(0)); if (this.settings.enableDetailedLogging) { console.log(`[${connectionId}] Sent application-level keep-alive to NetworkProxy connection`); } } catch (err) { // If we can't write, the connection is probably already dead if (this.settings.enableDetailedLogging) { console.log(`[${connectionId}] Error sending application-level keep-alive to NetworkProxy: ${err}`); } // Stop the timer if we hit an error clearInterval(keepAliveTimer); } } else { // Clean up timer if connection is gone clearInterval(keepAliveTimer); } }, 60000); // Send keep-alive every minute // Make sure interval doesn't prevent process exit if (keepAliveTimer.unref) { keepAliveTimer.unref(); } console.log(`[${connectionId}] Application-level keep-alive configured for NetworkProxy connection`); } if (this.settings.enableDetailedLogging) { console.log( `[${connectionId}] TLS connection successfully forwarded to NetworkProxy[${proxyIndex}]` ); } }); } /** * 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 { // Enhanced logging for initial connection troubleshooting if (serverName) { console.log(`[${connectionId}] Setting up direct connection for domain: ${serverName}`); } else { console.log(`[${connectionId}] Setting up direct connection without SNI`); } // Log domain config details to help diagnose routing issues if (domainConfig) { console.log(`[${connectionId}] Using domain config: ${domainConfig.domains.join(', ')}`); } else { console.log(`[${connectionId}] No specific domain config found, using default settings`); } // Ensure we maximize connection chances by setting appropriate timeouts socket.setTimeout(30000); // 30 second initial connect timeout // 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, // Add connection timeout to ensure we don't hang indefinitely timeout: 15000 // 15 second connection timeout }; if (this.settings.preserveSourceIP) { connectionOptions.localAddress = record.remoteIP.replace('::ffff:', ''); } console.log(`[${connectionId}] Connecting to backend: ${targetHost}:${connectionOptions.port}`); // Pause the incoming socket to prevent buffer overflows socket.pause(); // Temporary handler to collect data during connection setup const tempDataHandler = (chunk: Buffer) => { // Track bytes received record.bytesReceived += chunk.length; // Check for TLS handshake if (!record.isTLS && 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 return this.initiateCleanupOnce(record, 'buffer_limit_exceeded'); } // Buffer the chunk and update the size counter record.pendingData.push(Buffer.from(chunk)); record.pendingDataSize = newSize; this.updateActivity(record); }; // Add the temp handler to capture all incoming data during connection setup socket.on('data', tempDataHandler); // Add initial chunk to pending data if present - this is critical for SNI forwarding if (initialChunk) { // Make explicit copy of the buffer to ensure it doesn't get modified const initialDataCopy = Buffer.from(initialChunk); record.bytesReceived += initialDataCopy.length; record.pendingData.push(initialDataCopy); record.pendingDataSize = initialDataCopy.length; // Log TLS handshake for debug purposes if (isTlsHandshake(initialChunk)) { record.isTLS = true; console.log(`[${connectionId}] Buffered TLS handshake data: ${initialDataCopy.length} bytes, SNI: ${serverName || 'none'}`); } } else if (record.isTLS) { // This shouldn't happen, but log a warning if we have a TLS connection with no initial data console.log(`[${connectionId}] WARNING: TLS connection without initial handshake data`); } // 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) { // Use a slightly shorter initial delay for outgoing to ensure it stays active const outgoingInitialDelay = Math.max(this.settings.keepAliveInitialDelay - 5000, 5000); targetSocket.setKeepAlive(true, outgoingInitialDelay); record.outgoingKeepAliveEnabled = true; console.log(`[${connectionId}] Keep-alive enabled on outgoing connection with initial delay: ${outgoingInitialDelay}ms`); // Apply enhanced TCP keep-alive options if enabled if (this.settings.enableKeepAliveProbes) { try { if ('setKeepAliveProbes' in targetSocket) { (targetSocket as any).setKeepAliveProbes(10); // Same probes as incoming } if ('setKeepAliveInterval' in targetSocket) { // Use a shorter interval on outgoing for more reliable detection (targetSocket as any).setKeepAliveInterval(800); // Slightly faster than incoming } console.log(`[${connectionId}] Enhanced TCP keep-alive probes configured on outgoing connection`); } 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}` ); } } } // Special handling for TLS keep-alive - we want to be more aggressive // with keeping the outgoing connection alive in TLS mode if (record.isTLS) { // Set a timer to periodically send empty data to keep connection alive // This is in addition to TCP keep-alive, works at application layer const keepAliveTimer = setInterval(() => { if (targetSocket && !targetSocket.destroyed && record && !record.connectionClosed) { try { // Send 0-byte packet as application-level keep-alive targetSocket.write(Buffer.alloc(0)); if (this.settings.enableDetailedLogging) { console.log(`[${connectionId}] Sent application-level keep-alive to outgoing TLS connection`); } } catch (err) { // If we can't write, the connection is probably already dead if (this.settings.enableDetailedLogging) { console.log(`[${connectionId}] Error sending application-level keep-alive: ${err}`); } // Stop the timer if we hit an error clearInterval(keepAliveTimer); } } else { // Clean up timer if connection is gone clearInterval(keepAliveTimer); } }, 60000); // Send keep-alive every minute // Make sure interval doesn't prevent process exit if (keepAliveTimer.unref) { keepAliveTimer.unref(); } console.log(`[${connectionId}] Application-level keep-alive configured for TLS outgoing connection`); } } // Setup specific error handler for connection phase with enhanced retries 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(); // Add detailed logging for connection problems 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`); } // Log additional diagnostics console.log(`[${connectionId}] Connection details - SNI: ${serverName || 'none'}, HasChunk: ${!!initialChunk}, ChunkSize: ${initialChunk ? initialChunk.length : 0}`); // For TLS connections, provide even more detailed diagnostics if (record.isTLS) { console.log(`[${connectionId}] TLS connection failure details - TLS detected: ${record.isTLS}, Server: ${targetHost}:${connectionOptions.port}, Domain config: ${domainConfig ? 'Present' : 'Missing'}`); } // For connection refusal or timeouts, try a more aggressive error response // This helps browsers quickly realize there's an issue rather than waiting if (code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'EHOSTUNREACH') { try { // Send a RST packet rather than a graceful close // This signals to browsers to try a new connection immediately socket.destroy(new Error(`Backend connection failed: ${code}`)); console.log(`[${connectionId}] Forced connection termination to trigger immediate browser retry`); return; // Skip normal cleanup } catch (destroyErr) { console.log(`[${connectionId}] Error during forced connection termination: ${destroyErr}`); } } // 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)); // Remove temporary data handler socket.removeListener('data', tempDataHandler); // Flush all pending data to target if (record.pendingData.length > 0) { const combinedData = Buffer.concat(record.pendingData); // Add critical debugging for SNI forwarding issues if (record.isTLS && this.settings.enableTlsDebugLogging) { console.log(`[${connectionId}] Forwarding TLS handshake data: ${combinedData.length} bytes, SNI: ${serverName || 'none'}`); // Additional check to verify we're forwarding the ClientHello properly if (combinedData[0] === 22) { // TLS handshake console.log(`[${connectionId}] Initial data is a TLS handshake record`); } } // Write the combined data to the target targetSocket.write(combinedData, (err) => { if (err) { console.log(`[${connectionId}] Error writing pending data to target: ${err.message}`); return this.initiateCleanupOnce(record, 'write_error'); } if (record.isTLS) { // Log successful forwarding of initial TLS data console.log(`[${connectionId}] Successfully forwarded initial TLS data to backend`); } // Set up the renegotiation listener *before* piping if this is a TLS connection with SNI if (serverName && record.isTLS) { // Create a flag to prevent double-processing of the same handshake packet let processingRenegotiation = false; // This listener handles TLS renegotiation detection on the incoming socket socket.on('data', (renegChunk) => { // Only check for content type 22 (handshake) and not already processing if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22 && !processingRenegotiation) { processingRenegotiation = true; // Always update activity timestamp for any handshake packet this.updateActivity(record); try { // Enhanced logging for renegotiation console.log(`[${connectionId}] TLS handshake/renegotiation packet detected (${renegChunk.length} bytes)`); // Extract all TLS information including session resumption data const sniInfo = extractSNIInfo(renegChunk, this.settings.enableTlsDebugLogging); // Log details about the handshake packet if (this.settings.enableTlsDebugLogging) { console.log(`[${connectionId}] Handshake SNI extraction results:`, { isResumption: sniInfo?.isResumption || false, serverName: sniInfo?.serverName || 'none', resumedDomain: sniInfo?.resumedDomain || 'none', recordsExamined: sniInfo?.recordsExamined || 0, multipleRecords: sniInfo?.multipleRecords || false, partialExtract: sniInfo?.partialExtract || false }); } let newSNI = sniInfo?.serverName; // Handle session resumption - if we recognize the session ID, we know what domain it belongs to if (sniInfo?.isResumption && sniInfo.resumedDomain) { console.log(`[${connectionId}] Rehandshake with session resumption for domain: ${sniInfo.resumedDomain}`); newSNI = sniInfo.resumedDomain; } // IMPORTANT: If we can't extract an SNI from renegotiation, but we detected a TLS handshake, // we still need to make sure it's properly forwarded to maintain the TLS state if (newSNI === undefined) { console.log(`[${connectionId}] Rehandshake detected without SNI, forwarding transparently.`); // Set a temporary timeout to reset the processing flag setTimeout(() => { processingRenegotiation = false; }, 500); return; // Let the piping handle the forwarding } // Check if the SNI has changed if (newSNI !== serverName) { console.log(`[${connectionId}] Rehandshake with different SNI: ${newSNI} vs original ${serverName}`); // Allow if the new SNI matches existing domain config or find a new matching config let allowed = false; if (record.domainConfig) { allowed = record.domainConfig.domains.some(d => plugins.minimatch(newSNI, d)); } if (!allowed) { const newDomainConfig = this.settings.domainConfigs.find((config) => config.domains.some((d) => plugins.minimatch(newSNI, d)) ); if (newDomainConfig) { const effectiveAllowedIPs = [ ...newDomainConfig.allowedIPs, ...(this.settings.defaultAllowedIPs || []), ]; const effectiveBlockedIPs = [ ...(newDomainConfig.blockedIPs || []), ...(this.settings.defaultBlockedIPs || []), ]; allowed = isGlobIPAllowed(record.remoteIP, effectiveAllowedIPs, effectiveBlockedIPs); if (allowed) { record.domainConfig = newDomainConfig; } } } if (allowed) { console.log(`[${connectionId}] Updated domain for connection from ${record.remoteIP} to: ${newSNI}`); record.lockedDomain = newSNI; } else { console.log(`[${connectionId}] Rehandshake SNI ${newSNI} not allowed. Terminating connection.`); this.initiateCleanupOnce(record, 'sni_mismatch'); return; } } else { console.log(`[${connectionId}] Rehandshake with same SNI: ${newSNI}`); } } catch (err) { console.log(`[${connectionId}] Error processing renegotiation: ${err}. Allowing to continue.`); } finally { // Reset the processing flag after a small delay to prevent double-processing // of packets that may be part of the same handshake setTimeout(() => { processingRenegotiation = false; }, 500); } } }); // Set up a listener on the outgoing socket to detect issues with renegotiation // This helps catch cases where the outgoing connection has closed but the incoming is still active targetSocket.on('error', (err) => { // If we get an error during what might be a renegotiation, log it specially if (processingRenegotiation) { console.log(`[${connectionId}] ERROR: Outgoing socket error during TLS renegotiation: ${err.message}`); // Force immediate cleanup to prevent hanging connections this.initiateCleanupOnce(record, 'renegotiation_error'); } // The normal error handler will be called for other errors }); } // Now set up piping for future data and resume the socket socket.pipe(targetSocket); targetSocket.pipe(socket); socket.resume(); // Resume the socket after piping is established if (this.settings.enableDetailedLogging) { console.log( `[${connectionId}] Connection established: ${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(', ')})` : '' }` ); } }); } else { // Set up the renegotiation listener *before* piping if this is a TLS connection with SNI if (serverName && record.isTLS) { // Create a flag to prevent double-processing of the same handshake packet let processingRenegotiation = false; // This listener handles TLS renegotiation detection on the incoming socket socket.on('data', (renegChunk) => { // Only check for content type 22 (handshake) and not already processing if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22 && !processingRenegotiation) { processingRenegotiation = true; // Always update activity timestamp for any handshake packet this.updateActivity(record); try { // Enhanced logging for renegotiation console.log(`[${connectionId}] TLS handshake/renegotiation packet detected (${renegChunk.length} bytes)`); // Extract all TLS information including session resumption data const sniInfo = extractSNIInfo(renegChunk, this.settings.enableTlsDebugLogging); // Log details about the handshake packet if (this.settings.enableTlsDebugLogging) { console.log(`[${connectionId}] Handshake SNI extraction results:`, { isResumption: sniInfo?.isResumption || false, serverName: sniInfo?.serverName || 'none', resumedDomain: sniInfo?.resumedDomain || 'none', recordsExamined: sniInfo?.recordsExamined || 0, multipleRecords: sniInfo?.multipleRecords || false, partialExtract: sniInfo?.partialExtract || false }); } let newSNI = sniInfo?.serverName; // Handle session resumption - if we recognize the session ID, we know what domain it belongs to if (sniInfo?.isResumption && sniInfo.resumedDomain) { console.log(`[${connectionId}] Rehandshake with session resumption for domain: ${sniInfo.resumedDomain}`); newSNI = sniInfo.resumedDomain; } // IMPORTANT: If we can't extract an SNI from renegotiation, but we detected a TLS handshake, // we still need to make sure it's properly forwarded to maintain the TLS state if (newSNI === undefined) { console.log(`[${connectionId}] Rehandshake detected without SNI, forwarding transparently.`); // Set a temporary timeout to reset the processing flag setTimeout(() => { processingRenegotiation = false; }, 500); return; // Let the piping handle the forwarding } // Check if the SNI has changed if (newSNI !== serverName) { console.log(`[${connectionId}] Rehandshake with different SNI: ${newSNI} vs original ${serverName}`); // Allow if the new SNI matches existing domain config or find a new matching config let allowed = false; // First check if the new SNI is allowed under the existing domain config // This is the preferred approach as it maintains the existing connection context if (record.domainConfig) { allowed = record.domainConfig.domains.some(d => plugins.minimatch(newSNI, d)); if (allowed) { console.log(`[${connectionId}] Rehandshake SNI ${newSNI} allowed by existing domain config`); } } // If not allowed by existing config, try to find an alternative domain config if (!allowed) { // First try exact match let newDomainConfig = this.settings.domainConfigs.find((config) => config.domains.some((d) => plugins.minimatch(newSNI, d)) ); // If no exact match, try flexible matching with domain parts (for wildcard domains) if (!newDomainConfig) { console.log(`[${connectionId}] No exact domain config match for rehandshake SNI: ${newSNI}, trying flexible matching`); const domainParts = newSNI.split('.'); // Try matching with parent domains or wildcard patterns if (domainParts.length > 2) { const parentDomain = domainParts.slice(1).join('.'); const wildcardDomain = '*.' + parentDomain; console.log(`[${connectionId}] Trying alternative patterns: ${parentDomain} or ${wildcardDomain}`); newDomainConfig = this.settings.domainConfigs.find((config) => config.domains.some((d) => d === parentDomain || d === wildcardDomain || plugins.minimatch(parentDomain, d) ) ); } } if (newDomainConfig) { const effectiveAllowedIPs = [ ...newDomainConfig.allowedIPs, ...(this.settings.defaultAllowedIPs || []), ]; const effectiveBlockedIPs = [ ...(newDomainConfig.blockedIPs || []), ...(this.settings.defaultBlockedIPs || []), ]; allowed = isGlobIPAllowed(record.remoteIP, effectiveAllowedIPs, effectiveBlockedIPs); if (allowed) { record.domainConfig = newDomainConfig; } } } if (allowed) { console.log(`[${connectionId}] Updated domain for connection from ${record.remoteIP} to: ${newSNI}`); record.lockedDomain = newSNI; } else { console.log(`[${connectionId}] Rehandshake SNI ${newSNI} not allowed. Terminating connection.`); this.initiateCleanupOnce(record, 'sni_mismatch'); return; } } else { console.log(`[${connectionId}] Rehandshake with same SNI: ${newSNI}`); } } catch (err) { console.log(`[${connectionId}] Error processing renegotiation: ${err}. Allowing to continue.`); } finally { // Reset the processing flag after a small delay to prevent double-processing // of packets that may be part of the same handshake setTimeout(() => { processingRenegotiation = false; }, 500); } } }); // Set up a listener on the outgoing socket to detect issues with renegotiation // This helps catch cases where the outgoing connection has closed but the incoming is still active targetSocket.on('error', (err) => { // If we get an error during what might be a renegotiation, log it specially if (processingRenegotiation) { console.log(`[${connectionId}] ERROR: Outgoing socket error during TLS renegotiation: ${err.message}`); // Force immediate cleanup to prevent hanging connections this.initiateCleanupOnce(record, 'renegotiation_error'); } // The normal error handler will be called for other errors }); // Also monitor targetSocket for connection issues during client handshakes targetSocket.on('close', () => { // If the outgoing socket closes during renegotiation, it's a critical issue if (processingRenegotiation) { console.log(`[${connectionId}] CRITICAL: Outgoing socket closed during TLS renegotiation!`); console.log(`[${connectionId}] This likely explains cert mismatch errors in the browser.`); // Force immediate cleanup on the client side this.initiateCleanupOnce(record, 'target_closed_during_renegotiation'); } }); } // Now set up piping socket.pipe(targetSocket); targetSocket.pipe(socket); socket.resume(); // Resume the socket after piping is established if (this.settings.enableDetailedLogging) { console.log( `[${connectionId}] Connection established: ${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(', ')})` : '' }` ); } } // Clear the buffer now that we've processed it record.pendingData = []; record.pendingDataSize = 0; // Renegotiation detection is now handled before piping is established // This ensures the data listener receives all packets properly // 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 TLS keep-alive connections, use a more generous timeout now that // we've fixed the renegotiation handling issue that was causing certificate problems else if (record.hasKeepAlive && record.isTLS) { // Use a longer timeout for TLS connections now that renegotiation handling is fixed // This reduces unnecessary reconnections while still ensuring certificate freshness const tlsKeepAliveTimeout = 4 * 60 * 60 * 1000; // 4 hours for TLS keep-alive - increased from 30 minutes const safeTimeout = ensureSafeTimeout(tlsKeepAliveTimeout); record.cleanupTimer = setTimeout(() => { console.log( `[${connectionId}] TLS keep-alive connection from ${ record.remoteIP } exceeded max lifetime (${plugins.prettyMs( tlsKeepAliveTimeout )}), forcing cleanup to refresh certificate context.` ); this.initiateCleanupOnce(record, 'tls_certificate_refresh'); }, safeTimeout); // Make sure timeout doesn't keep the process alive if (record.cleanupTimer.unref) { record.cleanupTimer.unref(); } if (this.settings.enableDetailedLogging) { console.log( `[${connectionId}] TLS keep-alive connection with aggressive certificate refresh protection, lifetime: ${plugins.prettyMs( tlsKeepAliveTimeout )}` ); } } // 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; } /** * Update connection activity timestamp with enhanced sleep detection * Improved for chained proxy scenarios and more aggressive handling of stale connections */ private updateActivity(record: IConnectionRecord): void { // Get the current time const now = Date.now(); // Check if there was a large time gap that suggests system sleep if (record.lastActivity > 0) { const timeDiff = now - record.lastActivity; // Enhanced sleep detection with graduated thresholds - much more relaxed // Using chain detection from settings instead of recalculating const isChainedProxy = this.settings.isChainedProxy || false; const minuteInMs = 60 * 1000; const hourInMs = 60 * minuteInMs; // Significantly relaxed thresholds for better stability const shortInactivityThreshold = 30 * minuteInMs; // 30 minutes const mediumInactivityThreshold = 2 * hourInMs; // 2 hours const longInactivityThreshold = 8 * hourInMs; // 8 hours // Short inactivity (10-15 mins) - Might be temporary network issue or short sleep if (timeDiff > shortInactivityThreshold) { if (record.isTLS && !record.possibleSystemSleep) { // Record first detection of possible sleep/inactivity record.possibleSystemSleep = true; record.lastSleepDetection = now; if (this.settings.enableDetailedLogging) { console.log( `[${record.id}] Detected possible short inactivity for ${plugins.prettyMs(timeDiff)}. ` + `Monitoring for TLS connection health.` ); } // For TLS connections, send a minimal probe to check connection health if (!record.usingNetworkProxy && record.outgoing && !record.outgoing.destroyed) { try { record.outgoing.write(Buffer.alloc(0)); } catch (err) { console.log(`[${record.id}] Error sending TLS probe: ${err}`); } } } } // Medium inactivity (20-30 mins) - Likely a sleep event or network change if (timeDiff > mediumInactivityThreshold && record.hasKeepAlive) { console.log( `[${record.id}] Detected medium inactivity period for ${plugins.prettyMs(timeDiff)}. ` + `Taking proactive steps for connection health.` ); // For TLS connections, we need more aggressive handling if (record.isTLS && record.tlsHandshakeComplete) { // If in a chained proxy, we should be even more aggressive about refreshing if (isChainedProxy) { console.log( `[${record.id}] TLS connection in chained proxy inactive for ${plugins.prettyMs(timeDiff)}. ` + `Closing to prevent certificate inconsistencies across chain.` ); return this.initiateCleanupOnce(record, 'chained_proxy_inactivity'); } // For TLS in single proxy, try refresh first console.log( `[${record.id}] TLS connection inactive for ${plugins.prettyMs(timeDiff)}. ` + `Attempting active refresh of TLS state.` ); // Attempt deep TLS state refresh with buffer flush this.performDeepTlsRefresh(record); // Schedule verification check with tighter timing for chained setups const verificationTimeout = isChainedProxy ? 5 * minuteInMs : 10 * minuteInMs; const refreshCheckId = record.id; const refreshCheck = setTimeout(() => { const currentRecord = this.connectionRecords.get(refreshCheckId); if (currentRecord) { const verificationTimeDiff = Date.now() - currentRecord.lastActivity; if (verificationTimeDiff > verificationTimeout / 2) { console.log( `[${refreshCheckId}] No activity detected after TLS refresh (${plugins.prettyMs(verificationTimeDiff)}). ` + `Closing connection to ensure proper browser reconnection.` ); this.initiateCleanupOnce(currentRecord, 'tls_refresh_verification_failed'); } } }, verificationTimeout); // Make sure timeout doesn't keep the process alive if (refreshCheck.unref) { refreshCheck.unref(); } } // Update sleep detection markers record.possibleSystemSleep = true; record.lastSleepDetection = now; } // Long inactivity (60-120 mins) - Definite sleep/suspend or major network change if (timeDiff > longInactivityThreshold) { console.log( `[${record.id}] Detected long inactivity period of ${plugins.prettyMs(timeDiff)}. ` + `Closing connection to ensure fresh certificate context.` ); // For long periods, we always want to force close and let browser reconnect // This ensures fresh certificates and proper TLS context across the chain return this.initiateCleanupOnce(record, 'extended_inactivity_refresh'); } } // Update the activity timestamp record.lastActivity = now; // Clear any inactivity warning if (record.inactivityWarningIssued) { record.inactivityWarningIssued = false; } } /** * Perform deep TLS state refresh after sleep detection * More aggressive than the standard refresh, specifically designed for * recovering connections after system sleep in chained proxy setups */ private performDeepTlsRefresh(record: IConnectionRecord): void { // Skip if we're using a NetworkProxy as it handles its own TLS state if (record.usingNetworkProxy) { return; } try { // For outgoing connections that might need to be refreshed if (record.outgoing && !record.outgoing.destroyed) { // Check how long this connection has been established const connectionAge = Date.now() - record.incomingStartTime; const hourInMs = 60 * 60 * 1000; // For very long-lived connections, just close them if (connectionAge > 4 * hourInMs) { // Reduced from 8 hours to 4 hours for chained proxies console.log( `[${record.id}] Long-lived TLS connection (${plugins.prettyMs(connectionAge)}). ` + `Closing to ensure proper certificate handling across proxy chain.` ); return this.initiateCleanupOnce(record, 'certificate_age_refresh'); } // Perform a series of actions to try to refresh the TLS state // 1. Send a zero-length buffer to trigger any pending errors record.outgoing.write(Buffer.alloc(0)); // 2. Check socket state if (record.outgoing.writableEnded || !record.outgoing.writable) { console.log(`[${record.id}] Socket no longer writable during refresh`); return this.initiateCleanupOnce(record, 'socket_state_error'); } // 3. For TLS connections, try to force background renegotiation // by manipulating socket timeouts const originalTimeout = record.outgoing.timeout; record.outgoing.setTimeout(100); // Set very short timeout // 4. Create a small delay to allow timeout to process setTimeout(() => { try { if (record.outgoing && !record.outgoing.destroyed) { // Reset timeout to original value record.outgoing.setTimeout(originalTimeout || 0); // Send another probe with random data (16 bytes) that will be ignored by TLS layer // but might trigger internal state updates in the TLS implementation const probeBuffer = Buffer.alloc(16); // Fill with random data for (let i = 0; i < 16; i++) { probeBuffer[i] = Math.floor(Math.random() * 256); } record.outgoing.write(Buffer.alloc(0)); if (this.settings.enableDetailedLogging) { console.log(`[${record.id}] Completed deep TLS refresh sequence`); } } } catch (innerErr) { console.log(`[${record.id}] Error during deep TLS refresh: ${innerErr}`); this.initiateCleanupOnce(record, 'deep_refresh_error'); } }, 150); if (this.settings.enableDetailedLogging) { console.log(`[${record.id}] Initiated deep TLS refresh sequence`); } } } catch (err) { console.log(`[${record.id}] Error starting TLS state refresh: ${err}`); // If we hit an error, it's likely the connection is already broken // Force cleanup to ensure browser reconnects cleanly return this.initiateCleanupOnce(record, 'tls_refresh_error'); } } /** * Legacy refresh method for backward compatibility */ private refreshTlsStateAfterSleep(record: IConnectionRecord): void { return this.performDeepTlsRefresh(record); } /** * 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; 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 ? `, NetworkProxy: ${record.networkProxyIndex}` : ''}` ); } else { console.log( `[${record.id}] Connection from ${record.remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}` ); } } } /** * 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; } // 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 fields usingNetworkProxy: false, // Initialize sleep detection fields possibleSystemSleep: false, // Track keep-alive state for both sides of the connection incomingKeepAliveEnabled: false, outgoingKeepAliveEnabled: false, }; // Apply keep-alive settings if enabled if (this.settings.keepAlive) { // Configure incoming socket keep-alive socket.setKeepAlive(true, this.settings.keepAliveInitialDelay); connectionRecord.hasKeepAlive = true; // Mark connection as having keep-alive connectionRecord.incomingKeepAliveEnabled = true; console.log(`[${connectionId}] Keep-alive enabled on incoming connection with initial delay: ${this.settings.keepAliveInitialDelay}ms`); // 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 } console.log(`[${connectionId}] Enhanced TCP keep-alive probes configured on incoming connection`); } 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}` ); } let initialDataReceived = false; // 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); }; // 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 && 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 extractSNI(chunk, true); } } }); /** * Sets up the connection to the target host or NetworkProxy. * @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 && 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. let domainConfig = forcedDomain ? forcedDomain : serverName ? this.settings.domainConfigs.find((config) => config.domains.some((d) => plugins.minimatch(serverName, d)) ) : undefined; // Enhanced logging to diagnose domain config selection issues if (serverName && !domainConfig) { console.log(`[${connectionId}] WARNING: No domain config found for SNI: ${serverName}`); console.log(`[${connectionId}] Available domains:`, this.settings.domainConfigs.map(config => config.domains.join(',')).join(' | ')); } else if (serverName && domainConfig) { console.log(`[${connectionId}] Found domain config for SNI: ${serverName} -> ${domainConfig.domains.join(',')}`); } // For session resumption, ensure we use the domain config matching the resumed domain // The resumed domain will be in serverName if this is a session resumption if (serverName && connectionRecord.lockedDomain === serverName && serverName !== '') { // Override domain config lookup for session resumption - crucial for certificate selection // First try an exact match let resumedDomainConfig = this.settings.domainConfigs.find((config) => config.domains.some((d) => plugins.minimatch(serverName, d)) ); // If no exact match found, try a more flexible approach using domain parts if (!resumedDomainConfig) { console.log(`[${connectionId}] No exact domain config match for resumed domain: ${serverName}, trying flexible matching`); // Extract domain parts (e.g., for "sub.example.com" try matching with "*.example.com") const domainParts = serverName.split('.'); // Try matching with parent domains or wildcard patterns if (domainParts.length > 2) { const parentDomain = domainParts.slice(1).join('.'); const wildcardDomain = '*.' + parentDomain; console.log(`[${connectionId}] Trying alternative patterns: ${parentDomain} or ${wildcardDomain}`); resumedDomainConfig = this.settings.domainConfigs.find((config) => config.domains.some((d) => d === parentDomain || d === wildcardDomain || plugins.minimatch(parentDomain, d) ) ); } } if (resumedDomainConfig) { domainConfig = resumedDomainConfig; console.log(`[${connectionId}] Found domain config for resumed session: ${serverName} -> ${resumedDomainConfig.domains.join(',')}`); } else { // As a fallback, use the first domain config with the same target IP if possible if (domainConfig && domainConfig.targetIPs && domainConfig.targetIPs.length > 0) { const targetIP = domainConfig.targetIPs[0]; const similarConfig = this.settings.domainConfigs.find((config) => config.targetIPs && config.targetIPs.includes(targetIP) ); if (similarConfig && similarConfig !== domainConfig) { console.log(`[${connectionId}] Using similar domain config with matching target IP for resumed domain: ${serverName}`); domainConfig = similarConfig; } else { console.log(`[${connectionId}] WARNING: Cannot find domain config for resumed domain: ${serverName}`); // Log available domains to help diagnose the issue console.log(`[${connectionId}] Available domains:`, this.settings.domainConfigs.map(config => config.domains.join(',')).join(' | ')); } } else { console.log(`[${connectionId}] WARNING: Cannot find domain config for resumed domain: ${serverName}`); // Log available domains to help diagnose the issue console.log(`[${connectionId}] Available domains:`, this.settings.domainConfigs.map(config => config.domains.join(',')).join(' | ')); } } } // Save domain config in connection record connectionRecord.domainConfig = domainConfig; // Always set the lockedDomain, even for non-SNI connections if (serverName) { connectionRecord.lockedDomain = serverName; console.log(`[${connectionId}] Locked connection to domain: ${serverName}`); } // 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( ', ' )}` ); } // Check if we should forward this to a NetworkProxy if ( isTlsHandshakeDetected && domainConfig.useNetworkProxy === true && initialChunk && this.networkProxies.length > 0 ) { return this.forwardToNetworkProxy( connectionId, socket, connectionRecord, domainConfig, initialChunk, serverName ); } } 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` ); } } // If we didn't forward to NetworkProxy, proceed with 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 - with enhanced logging for troubleshooting let serverName = ''; // Record the chunk size for diagnostic purposes console.log(`[${connectionId}] Received initial data: ${chunk.length} bytes`); if (isTlsHandshake(chunk)) { connectionRecord.isTLS = true; console.log(`[${connectionId}] Detected TLS handshake`); if (this.settings.enableTlsDebugLogging) { console.log( `[${connectionId}] Extracting SNI from TLS handshake, ${chunk.length} bytes` ); } // Extract all TLS information including session resumption const sniInfo = extractSNIInfo(chunk, this.settings.enableTlsDebugLogging); if (sniInfo?.isResumption && sniInfo.resumedDomain) { // This is a session resumption with a known domain serverName = sniInfo.resumedDomain; console.log(`[${connectionId}] TLS Session resumption detected for domain: ${serverName}`); // When resuming a session, explicitly set the domain in the record to ensure proper routing // This is CRITICAL for ensuring we select the correct backend/certificate connectionRecord.lockedDomain = serverName; // Force detailed logging for resumed sessions to help with troubleshooting console.log(`[${connectionId}] Resuming TLS session for domain ${serverName} - will use original certificate`); } else { // Normal SNI extraction serverName = sniInfo?.serverName || ''; if (serverName) { console.log(`[${connectionId}] Extracted SNI domain: ${serverName}`); } else { console.log(`[${connectionId}] No SNI found in TLS handshake`); } } } else { console.log(`[${connectionId}] Non-TLS connection detected`); } // 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, () => { console.log( `PortProxy -> OK: Now listening on port ${port}${ this.settings.sniEnabled ? ' (SNI passthrough enabled)' : '' }${this.networkProxies.length > 0 ? ' (NetworkProxy integration 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; // 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++; } 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; // Special handling for TLS keep-alive connections if ( record.hasKeepAlive && record.isTLS && inactivityTime > this.settings.inactivityTimeout! / 2 ) { // For TLS keep-alive connections that are getting stale, try to refresh before closing if (!record.inactivityWarningIssued) { console.log( `[${id}] TLS keep-alive connection from ${ record.remoteIP } inactive for ${plugins.prettyMs(inactivityTime)}. ` + `Attempting to preserve connection.` ); // Set warning flag but give a much longer grace period for TLS connections record.inactivityWarningIssued = true; // For TLS connections, extend the last activity time considerably // This gives browsers more time to re-establish the connection properly record.lastActivity = now - this.settings.inactivityTimeout! / 3; // Try to stimulate the connection with a probe packet if (record.outgoing && !record.outgoing.destroyed) { try { // For TLS connections, send a proper TLS heartbeat-like packet // This is just a small empty buffer that won't affect the TLS session record.outgoing.write(Buffer.alloc(0)); if (this.settings.enableDetailedLogging) { console.log(`[${id}] Sent TLS keep-alive probe packet`); } } catch (err) { console.log(`[${id}] Error sending TLS probe packet: ${err}`); } } // Don't proceed to the normal inactivity check logic continue; } } // 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 { // MODIFIED: For TLS connections, be more lenient before closing // For TLS browser connections, we need to handle certificate context properly if (record.isTLS && record.hasKeepAlive) { // For very long inactivity, it's better to close the connection // so the browser establishes a new one with a fresh certificate context if (inactivityTime > 6 * 60 * 60 * 1000) { // 6 hours console.log( `[${id}] TLS keep-alive connection from ${ record.remoteIP } inactive for ${plugins.prettyMs(inactivityTime)}. ` + `Closing to ensure proper certificate handling on browser reconnect.` ); this.cleanupConnection(record, 'tls_certificate_refresh'); } else { // For shorter inactivity periods, add grace period console.log( `[${id}] TLS keep-alive connection from ${ record.remoteIP } inactive for ${plugins.prettyMs(inactivityTime)}. ` + `Adding extra grace period.` ); // Give additional time for browsers to reconnect properly record.lastActivity = now - effectiveTimeout / 2; } } 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}. ` + `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(); } } /** * Add or replace NetworkProxy instances */ public setNetworkProxies(networkProxies: NetworkProxy[]): void { this.networkProxies = networkProxies; console.log(`Updated NetworkProxy instances: ${this.networkProxies.length} proxies configured`); } /** * Get a list of configured NetworkProxy instances */ public getNetworkProxies(): NetworkProxy[] { return this.networkProxies; } /** * Gracefully shut down the proxy */ public async stop() { console.log('PortProxy shutting down...'); this.isShuttingDown = true; // Stop the session cleanup timer stopSessionCleanupTimer(); // Stop accepting new connections const closeServerPromises: Promise[] = this.netServers.map( (server) => new Promise((resolve) => { if (!server.listening) { resolve(); return; } server.close((err) => { if (err) { console.log(`Error closing server: ${err.message}`); } resolve(); }); }) ); // Stop the connection logger if (this.connectionLogger) { clearInterval(this.connectionLogger); this.connectionLogger = null; } // Wait for servers to close await Promise.all(closeServerPromises); console.log('All servers closed. Cleaning up active connections...'); // Force destroy all active connections immediately const connectionIds = [...this.connectionRecords.keys()]; console.log(`Cleaning up ${connectionIds.length} active connections...`); // First pass: End all connections gracefully for (const id of connectionIds) { const record = this.connectionRecords.get(id); if (record) { try { // Clear any timers if (record.cleanupTimer) { clearTimeout(record.cleanupTimer); record.cleanupTimer = undefined; } // End sockets gracefully if (record.incoming && !record.incoming.destroyed) { record.incoming.end(); } if (record.outgoing && !record.outgoing.destroyed) { record.outgoing.end(); } } catch (err) { console.log(`Error during graceful connection end for ${id}: ${err}`); } } } // Short delay to allow graceful ends to process await new Promise((resolve) => setTimeout(resolve, 100)); // Second pass: Force destroy everything for (const id of connectionIds) { const record = this.connectionRecords.get(id); if (record) { try { // Remove all listeners to prevent memory leaks if (record.incoming) { record.incoming.removeAllListeners(); if (!record.incoming.destroyed) { record.incoming.destroy(); } } if (record.outgoing) { record.outgoing.removeAllListeners(); if (!record.outgoing.destroyed) { record.outgoing.destroy(); } } } catch (err) { console.log(`Error during forced connection destruction for ${id}: ${err}`); } } } // Clear all tracking maps this.connectionRecords.clear(); this.domainTargetIndices.clear(); this.connectionsByIP.clear(); this.connectionRateByIP.clear(); this.netServers = []; // Reset termination stats this.terminationStats = { incoming: {}, outgoing: {}, }; console.log('PortProxy shutdown complete.'); } }