diff --git a/changelog.md b/changelog.md index c4566a1..b533d6b 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2025-03-05 - 3.25.4 - fix(portproxy) +Improve connection timeouts and detailed logging for PortProxy + +- Refactored timeout management for connections to include enhanced defaults and prevent thundering herd. +- Improved support for TLS handshake detection with logging capabilities in PortProxy. +- Removed protocol-specific handling which is now managed generically. +- Introduced enhanced logging for SNI extraction and connection management. + ## 2025-03-05 - 3.25.3 - fix(core) Update dependencies and configuration improvements. diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 21a99b0..03fe8ab 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '3.25.3', + version: '3.25.4', description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.' } diff --git a/ts/classes.portproxy.ts b/ts/classes.portproxy.ts index 032fa3f..01f19db 100644 --- a/ts/classes.portproxy.ts +++ b/ts/classes.portproxy.ts @@ -7,14 +7,10 @@ export interface IDomainConfig { blockedIPs?: string[]; // Glob patterns for blocked IPs targetIPs?: string[]; // If multiple targetIPs are given, use round robin. portRanges?: Array<{ from: number; to: number }>; // Optional port ranges - // Protocol-specific timeout overrides - httpTimeout?: number; // HTTP connection timeout override (ms) - wsTimeout?: number; // WebSocket connection timeout override (ms) + // Allow domain-specific timeout override + connectionTimeout?: number; // Connection timeout override (ms) } -/** Connection protocol types for timeout management */ -export type ProtocolType = 'http' | 'websocket' | 'https' | 'tls' | 'unknown'; - /** Port proxy settings including global allowed port ranges */ export interface IPortProxySettings extends plugins.tls.TlsOptions { fromPort: number; @@ -26,40 +22,37 @@ export interface IPortProxySettings extends plugins.tls.TlsOptions { defaultBlockedIPs?: string[]; preserveSourceIP?: boolean; - // Updated timeout settings with better defaults - initialDataTimeout?: number; // Timeout for initial data/SNI (ms), default: 15000 (15s) - socketTimeout?: number; // Socket inactivity timeout (ms), default: 300000 (5m) - inactivityCheckInterval?: number; // How often to check for inactive connections (ms), default: 30000 (30s) + // Timeout settings + initialDataTimeout?: number; // Timeout for initial data/SNI (ms), default: 60000 (60s) + socketTimeout?: number; // Socket inactivity timeout (ms), default: 3600000 (1h) + inactivityCheckInterval?: number; // How often to check for inactive connections (ms), default: 60000 (60s) + maxConnectionLifetime?: number; // Default max connection lifetime (ms), default: 3600000 (1h) + inactivityTimeout?: number; // Inactivity timeout (ms), default: 3600000 (1h) - // Protocol-specific timeouts - maxConnectionLifetime?: number; // Default max connection lifetime (ms), default: 3600000 (1h) - httpConnectionTimeout?: number; // HTTP specific timeout (ms), default: 1800000 (30m) - wsConnectionTimeout?: number; // WebSocket specific timeout (ms), default: 14400000 (4h) - httpKeepAliveTimeout?: number; // HTTP keep-alive header timeout (ms), default: 1200000 (20m) - - gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown + gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown globalPortRanges: Array<{ from: number; to: number }>; // Global allowed port ranges - forwardAllGlobalRanges?: boolean; // When true, forwards all connections on global port ranges to the global targetIP + 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 + noDelay?: boolean; // Disable Nagle's algorithm (default: true) + keepAlive?: boolean; // Enable TCP keepalive (default: true) + keepAliveInitialDelay?: number; // Initial delay before sending keepalive probes (ms) + maxPendingDataSize?: number; // Maximum bytes to buffer during connection setup - // Enable enhanced features - disableInactivityCheck?: boolean; // Disable inactivity checking entirely - enableKeepAliveProbes?: boolean; // Enable TCP keep-alive probes - enableProtocolDetection?: boolean; // Enable HTTP/WebSocket protocol detection - enableDetailedLogging?: boolean; // Enable detailed connection logging + // Enhanced features + disableInactivityCheck?: boolean; // Disable inactivity checking entirely + enableKeepAliveProbes?: boolean; // Enable TCP keep-alive probes + enableDetailedLogging?: boolean; // Enable detailed connection logging + enableTlsDebugLogging?: boolean; // Enable TLS handshake debug logging + enableRandomizedTimeouts?: boolean; // Randomize timeouts slightly to prevent thundering herd // Rate limiting and security - maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP + maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP connectionRateLimitPerMinute?: number; // Max new connections per minute from a single IP } /** - * Enhanced connection record with protocol-specific handling + * Enhanced connection record */ interface IConnectionRecord { id: string; // Unique connection identifier @@ -76,78 +69,161 @@ interface IConnectionRecord { pendingDataSize: number; // Track total size of pending data // Enhanced tracking fields - protocolType: ProtocolType; // Connection protocol type - isPooledConnection: boolean; // Whether this is likely a browser pooled connection - lastHttpRequest?: number; // Timestamp of last HTTP request (for keep-alive tracking) - httpKeepAliveTimeout?: number; // HTTP keep-alive timeout from headers bytesReceived: number; // Total bytes received bytesSent: number; // Total bytes sent remoteIP: string; // Remote IP (cached for logging after socket close) localPort: number; // Local port (cached for logging) - httpRequests: number; // Count of HTTP requests on this connection + 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 } /** * Extracts the SNI (Server Name Indication) from a TLS ClientHello packet. + * Enhanced for robustness and detailed logging. * @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): string | undefined { - let offset = 0; - if (buffer.length < 5) return undefined; - - const recordType = buffer.readUInt8(0); - if (recordType !== 22) return undefined; // 22 = handshake - - const recordLength = buffer.readUInt16BE(3); - if (buffer.length < 5 + recordLength) return undefined; - - offset = 5; - const handshakeType = buffer.readUInt8(offset); - if (handshakeType !== 1) return undefined; // 1 = ClientHello - - offset += 4; // Skip handshake header (type + length) - offset += 2 + 32; // Skip client version and random - - const sessionIDLength = buffer.readUInt8(offset); - offset += 1 + sessionIDLength; // Skip session ID - - const cipherSuitesLength = buffer.readUInt16BE(offset); - offset += 2 + cipherSuitesLength; // Skip cipher suites - - const compressionMethodsLength = buffer.readUInt8(offset); - offset += 1 + compressionMethodsLength; // Skip compression methods - - if (offset + 2 > buffer.length) return undefined; - const extensionsLength = buffer.readUInt16BE(offset); - offset += 2; - const extensionsEnd = offset + extensionsLength; - - while (offset + 4 <= extensionsEnd) { - const extensionType = buffer.readUInt16BE(offset); - const extensionLength = buffer.readUInt16BE(offset + 2); - offset += 4; - if (extensionType === 0x0000) { // SNI extension - if (offset + 2 > buffer.length) return undefined; - const sniListLength = buffer.readUInt16BE(offset); - offset += 2; - const sniListEnd = offset + sniListLength; - while (offset + 3 < sniListEnd) { - const nameType = buffer.readUInt8(offset++); - const nameLen = buffer.readUInt16BE(offset); - offset += 2; - if (nameType === 0) { // host_name - if (offset + nameLen > buffer.length) return undefined; - return buffer.toString('utf8', offset, offset + nameLen); - } - offset += nameLen; - } - break; - } else { - offset += extensionLength; +function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | 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 undefined; } + + // Check 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; + } + + // Check TLS version (has to be 3.1 or higher) + const majorVersion = buffer.readUInt8(1); + const minorVersion = buffer.readUInt8(2); + if (enableLogging) console.log(`TLS Version: ${majorVersion}.${minorVersion}`); + + // Check record length + const recordLength = buffer.readUInt16BE(3); + if (buffer.length < 5 + recordLength) { + if (enableLogging) console.log(`Buffer too small for TLS record. Expected: ${5 + recordLength}, Got: ${buffer.length}`); + return undefined; + } + + let offset = 5; + const handshakeType = buffer.readUInt8(offset); + if (handshakeType !== 1) { + if (enableLogging) console.log(`Not a ClientHello. Handshake type: ${handshakeType}`); + return undefined; + } + + offset += 4; // Skip handshake header (type + length) + + // Client version + const clientMajorVersion = buffer.readUInt8(offset); + const clientMinorVersion = buffer.readUInt8(offset + 1); + if (enableLogging) console.log(`Client Version: ${clientMajorVersion}.${clientMinorVersion}`); + + offset += 2 + 32; // Skip client version and random + + // Session ID + const sessionIDLength = buffer.readUInt8(offset); + if (enableLogging) console.log(`Session ID Length: ${sessionIDLength}`); + offset += 1 + sessionIDLength; // Skip session ID + + // Cipher suites + if (offset + 2 > buffer.length) { + if (enableLogging) console.log("Buffer too small for cipher suites length"); + return undefined; + } + const cipherSuitesLength = buffer.readUInt16BE(offset); + if (enableLogging) console.log(`Cipher Suites Length: ${cipherSuitesLength}`); + offset += 2 + cipherSuitesLength; // Skip cipher suites + + // Compression methods + if (offset + 1 > buffer.length) { + if (enableLogging) console.log("Buffer too small for compression methods length"); + return undefined; + } + const compressionMethodsLength = buffer.readUInt8(offset); + if (enableLogging) console.log(`Compression Methods Length: ${compressionMethodsLength}`); + offset += 1 + compressionMethodsLength; // Skip compression methods + + // Extensions + if (offset + 2 > buffer.length) { + if (enableLogging) console.log("Buffer too small for extensions length"); + return undefined; + } + const extensionsLength = buffer.readUInt16BE(offset); + if (enableLogging) console.log(`Extensions Length: ${extensionsLength}`); + offset += 2; + const extensionsEnd = offset + extensionsLength; + + if (extensionsEnd > buffer.length) { + if (enableLogging) console.log(`Buffer too small for extensions. Expected end: ${extensionsEnd}, Buffer length: ${buffer.length}`); + return undefined; + } + + // Parse extensions + while (offset + 4 <= extensionsEnd) { + const extensionType = buffer.readUInt16BE(offset); + const extensionLength = buffer.readUInt16BE(offset + 2); + + if (enableLogging) console.log(`Extension Type: 0x${extensionType.toString(16)}, Length: ${extensionLength}`); + + offset += 4; + + if (extensionType === 0x0000) { // SNI extension + if (offset + 2 > buffer.length) { + if (enableLogging) console.log("Buffer too small for SNI list length"); + return undefined; + } + + const sniListLength = buffer.readUInt16BE(offset); + if (enableLogging) console.log(`SNI List Length: ${sniListLength}`); + offset += 2; + const sniListEnd = offset + sniListLength; + + if (sniListEnd > buffer.length) { + if (enableLogging) console.log(`Buffer too small for SNI list. Expected end: ${sniListEnd}, Buffer length: ${buffer.length}`); + return undefined; + } + + while (offset + 3 < sniListEnd) { + const nameType = buffer.readUInt8(offset++); + const nameLen = buffer.readUInt16BE(offset); + offset += 2; + + if (enableLogging) console.log(`Name Type: ${nameType}, Name Length: ${nameLen}`); + + if (nameType === 0) { // host_name + if (offset + nameLen > buffer.length) { + if (enableLogging) console.log(`Buffer too small for hostname. Expected: ${offset + nameLen}, Got: ${buffer.length}`); + return undefined; + } + + const serverName = buffer.toString('utf8', offset, offset + nameLen); + if (enableLogging) console.log(`Extracted SNI: ${serverName}`); + return serverName; + } + + offset += nameLen; + } + break; + } else { + offset += extensionLength; + } + } + + if (enableLogging) console.log("No SNI extension found"); + return undefined; + } catch (err) { + console.log(`Error extracting SNI: ${err}`); + return undefined; } - return undefined; } // Helper: Check if a port falls within any of the given port ranges @@ -157,7 +233,10 @@ const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number } // 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]; @@ -167,7 +246,10 @@ const isAllowed = (ip: string, patterns: string[]): boolean => { } 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)) @@ -176,6 +258,7 @@ const isAllowed = (ip: string, patterns: string[]): boolean => { // 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); }; @@ -185,34 +268,17 @@ const generateConnectionId = (): string => { return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); }; -// Protocol detection helpers -const isHttpRequest = (buffer: Buffer): boolean => { - if (buffer.length < 4) return false; - const start = buffer.toString('ascii', 0, 4).toUpperCase(); - return ( - start.startsWith('GET ') || - start.startsWith('POST') || - start.startsWith('PUT ') || - start.startsWith('HEAD') || - start.startsWith('DELE') || - start.startsWith('PATC') || - start.startsWith('OPTI') - ); -}; - -const isWebSocketUpgrade = (buffer: Buffer): boolean => { - if (buffer.length < 20) return false; - const data = buffer.toString('ascii', 0, Math.min(buffer.length, 200)); - return ( - data.includes('Upgrade: websocket') || - data.includes('Upgrade: WebSocket') - ); -}; - +// Helper: Check if a buffer contains a TLS handshake const isTlsHandshake = (buffer: Buffer): boolean => { return buffer.length > 0 && buffer[0] === 22; // ContentType.handshake }; +// Helper: Generate a slightly randomized timeout to prevent thundering herd +const randomizeTimeout = (baseTimeout: number, variationPercent: number = 5): number => { + const variation = baseTimeout * (variationPercent / 100); + return baseTimeout + Math.floor(Math.random() * variation * 2) - variation; +}; + export class PortProxy { private netServers: plugins.net.Server[] = []; settings: IPortProxySettings; @@ -242,16 +308,12 @@ export class PortProxy { ...settingsArg, targetIP: settingsArg.targetIP || 'localhost', - // Timeout settings with browser-friendly defaults - initialDataTimeout: settingsArg.initialDataTimeout || 30000, // 30 seconds - socketTimeout: settingsArg.socketTimeout || 300000, // 5 minutes - inactivityCheckInterval: settingsArg.inactivityCheckInterval || 30000, // 30 seconds - - // Protocol-specific timeouts - maxConnectionLifetime: settingsArg.maxConnectionLifetime || 3600000, // 1 hour default - httpConnectionTimeout: settingsArg.httpConnectionTimeout || 1800000, // 30 minutes - wsConnectionTimeout: settingsArg.wsConnectionTimeout || 14400000, // 4 hours - httpKeepAliveTimeout: settingsArg.httpKeepAliveTimeout || 1200000, // 20 minutes + // Timeout settings with our enhanced defaults + initialDataTimeout: settingsArg.initialDataTimeout || 60000, // 60 seconds for initial data + socketTimeout: settingsArg.socketTimeout || 3600000, // 1 hour socket timeout + inactivityCheckInterval: settingsArg.inactivityCheckInterval || 60000, // 60 seconds interval + maxConnectionLifetime: settingsArg.maxConnectionLifetime || 3600000, // 1 hour default lifetime + inactivityTimeout: settingsArg.inactivityTimeout || 3600000, // 1 hour inactivity timeout gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000, // 30 seconds @@ -259,13 +321,14 @@ export class PortProxy { noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true, keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true, keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 30000, // 30 seconds - maxPendingDataSize: settingsArg.maxPendingDataSize || 1024 * 1024, // 1MB + maxPendingDataSize: settingsArg.maxPendingDataSize || 10 * 1024 * 1024, // 10MB to handle large TLS handshakes // Feature flags disableInactivityCheck: settingsArg.disableInactivityCheck || false, enableKeepAliveProbes: settingsArg.enableKeepAliveProbes || false, - enableProtocolDetection: settingsArg.enableProtocolDetection !== undefined ? settingsArg.enableProtocolDetection : true, enableDetailedLogging: settingsArg.enableDetailedLogging || false, + enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false, + enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || true, // Rate limiting defaults maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100, // 100 connections per IP @@ -332,115 +395,22 @@ export class PortProxy { } /** - * Get protocol-specific timeout based on connection type + * Get connection timeout based on domain config or default settings */ - private getProtocolTimeout(record: IConnectionRecord, domainConfig?: IDomainConfig): number { - // If the protocol has a domain-specific timeout, use that - if (domainConfig) { - if (record.protocolType === 'http' && domainConfig.httpTimeout) { - return domainConfig.httpTimeout; - } - if (record.protocolType === 'websocket' && domainConfig.wsTimeout) { - return domainConfig.wsTimeout; - } - } - - // Use HTTP keep-alive timeout from headers if available - if (record.httpKeepAliveTimeout) { - return record.httpKeepAliveTimeout; + private getConnectionTimeout(record: IConnectionRecord): number { + // If the connection has a domain-specific timeout, use that + if (record.domainConfig?.connectionTimeout) { + return record.domainConfig.connectionTimeout; } - // Otherwise use default protocol-specific timeout - switch (record.protocolType) { - case 'http': - return this.settings.httpConnectionTimeout!; - case 'websocket': - return this.settings.wsConnectionTimeout!; - case 'https': - case 'tls': - return this.settings.httpConnectionTimeout!; // Use HTTP timeout for HTTPS by default - default: - return this.settings.maxConnectionLifetime!; - } - } - - /** - * Detect protocol and update connection record - */ - private detectProtocol(data: Buffer, record: IConnectionRecord): void { - if (!this.settings.enableProtocolDetection || record.protocolType !== 'unknown') { - return; - } - - try { - // Detect TLS/HTTPS - if (isTlsHandshake(data)) { - record.protocolType = 'tls'; - if (this.settings.enableDetailedLogging) { - console.log(`[${record.id}] Protocol detected: TLS`); - } - return; - } - - // Detect HTTP including WebSocket upgrades - if (isHttpRequest(data)) { - record.httpRequests++; - record.lastHttpRequest = Date.now(); - - // Check for WebSocket upgrade - if (isWebSocketUpgrade(data)) { - record.protocolType = 'websocket'; - if (this.settings.enableDetailedLogging) { - console.log(`[${record.id}] Protocol detected: WebSocket Upgrade`); - } - } else { - record.protocolType = 'http'; - - // Parse HTTP keep-alive headers - this.parseHttpHeaders(data, record); - - if (this.settings.enableDetailedLogging) { - console.log(`[${record.id}] Protocol detected: HTTP${record.isPooledConnection ? ' (KeepAlive)' : ''}`); - } - } - } - } catch (err) { - console.log(`[${record.id}] Error detecting protocol: ${err}`); - } - } - - /** - * Parse HTTP headers for keep-alive and other connection info - */ - private parseHttpHeaders(data: Buffer, record: IConnectionRecord): void { - try { - const headerStr = data.toString('utf8', 0, Math.min(data.length, 1024)); - - // Check for HTTP keep-alive - const connectionHeader = headerStr.match(/\r\nConnection:\s*([^\r\n]+)/i); - if (connectionHeader && connectionHeader[1].toLowerCase().includes('keep-alive')) { - record.isPooledConnection = true; - - // Check for Keep-Alive timeout value - const keepAliveHeader = headerStr.match(/\r\nKeep-Alive:\s*([^\r\n]+)/i); - if (keepAliveHeader) { - const timeoutMatch = keepAliveHeader[1].match(/timeout=(\d+)/i); - if (timeoutMatch && timeoutMatch[1]) { - const timeoutSec = parseInt(timeoutMatch[1], 10); - if (!isNaN(timeoutSec) && timeoutSec > 0) { - // Convert seconds to milliseconds and add some buffer - record.httpKeepAliveTimeout = (timeoutSec * 1000) + 5000; - - if (this.settings.enableDetailedLogging) { - console.log(`[${record.id}] HTTP Keep-Alive timeout set to ${timeoutSec} seconds`); - } - } - } - } - } - } catch (err) { - console.log(`[${record.id}] Error parsing HTTP headers: ${err}`); + // Use default timeout, potentially randomized + const baseTimeout = this.settings.maxConnectionLifetime!; + + if (this.settings.enableRandomizedTimeouts) { + return randomizeTimeout(baseTimeout); } + + return baseTimeout; } /** @@ -465,7 +435,6 @@ export class PortProxy { const duration = Date.now() - record.incomingStartTime; const bytesReceived = record.bytesReceived; const bytesSent = record.bytesSent; - const httpRequests = record.httpRequests; try { if (!record.incoming.destroyed) { @@ -538,7 +507,7 @@ export class PortProxy { if (this.settings.enableDetailedLogging) { console.log(`[${record.id}] Connection from ${record.remoteIP} on port ${record.localPort} terminated (${reason}).` + ` Duration: ${plugins.prettyMs(duration)}, Bytes IN: ${bytesReceived}, OUT: ${bytesSent}, ` + - `HTTP Requests: ${httpRequests}, Protocol: ${record.protocolType}, Pooled: ${record.isPooledConnection}`); + `TLS: ${record.isTLS ? 'Yes' : 'No'}`); } else { console.log(`[${record.id}] Connection from ${record.remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}`); } @@ -608,6 +577,21 @@ export class PortProxy { socket.setKeepAlive(true, this.settings.keepAliveInitialDelay); } + // Apply enhanced TCP options if available + if (this.settings.enableKeepAliveProbes) { + try { + // These are platform-specific and may not be available + if ('setKeepAliveProbes' in socket) { + (socket as any).setKeepAliveProbes(10); + } + if ('setKeepAliveInterval' in socket) { + (socket as any).setKeepAliveInterval(1000); + } + } catch (err) { + // Ignore errors - these are optional enhancements + } + } + // Create a unique connection ID and record const connectionId = generateConnectionId(); const connectionRecord: IConnectionRecord = { @@ -621,13 +605,13 @@ export class PortProxy { pendingDataSize: 0, // Initialize enhanced tracking fields - protocolType: 'unknown', - isPooledConnection: false, bytesReceived: 0, bytesSent: 0, remoteIP: remoteIP, localPort: localPort, - httpRequests: 0 + isTLS: false, + tlsHandshakeComplete: false, + hasReceivedInitialData: false }; // Track connection by IP @@ -685,9 +669,15 @@ export class PortProxy { socket.end(); cleanupOnce(); } - }, this.settings.initialDataTimeout); + }, 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', (err: Error) => { @@ -699,39 +689,14 @@ export class PortProxy { connectionRecord.bytesReceived += chunk.length; this.updateActivity(connectionRecord); - // Detect protocol on first data chunk - if (connectionRecord.protocolType === 'unknown') { - this.detectProtocol(chunk, connectionRecord); + // Check for TLS handshake if this is the first chunk + if (!connectionRecord.isTLS && isTlsHandshake(chunk)) { + connectionRecord.isTLS = true; - // Update timeout based on protocol - if (connectionRecord.cleanupTimer) { - clearTimeout(connectionRecord.cleanupTimer); - - // Set new timeout based on protocol - const protocolTimeout = this.getProtocolTimeout(connectionRecord); - connectionRecord.cleanupTimer = setTimeout(() => { - console.log(`[${connectionId}] ${connectionRecord.protocolType} connection timeout after ${plugins.prettyMs(protocolTimeout)}`); - initiateCleanupOnce(`${connectionRecord.protocolType}_timeout`); - }, protocolTimeout); - } - } else if (connectionRecord.protocolType === 'http' && isHttpRequest(chunk)) { - // Additional HTTP request on the same connection - connectionRecord.httpRequests++; - connectionRecord.lastHttpRequest = Date.now(); - - // Parse HTTP headers again for keep-alive changes - this.parseHttpHeaders(chunk, connectionRecord); - - // Update timeout based on new HTTP headers - if (connectionRecord.cleanupTimer) { - clearTimeout(connectionRecord.cleanupTimer); - - // Set new timeout based on updated HTTP info - const protocolTimeout = this.getProtocolTimeout(connectionRecord); - connectionRecord.cleanupTimer = setTimeout(() => { - console.log(`[${connectionId}] HTTP connection timeout after ${plugins.prettyMs(protocolTimeout)}`); - initiateCleanupOnce('http_timeout'); - }, protocolTimeout); + 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); } } }); @@ -797,9 +762,17 @@ export class PortProxy { initialTimeout = null; } - // Detect protocol if initial chunk is available - if (initialChunk && this.settings.enableProtocolDetection) { - this.detectProtocol(initialChunk, connectionRecord); + // Mark that we've received initial data + initialDataReceived = true; + connectionRecord.hasReceivedInitialData = true; + + // Check if this looks like a TLS handshake + if (initialChunk && isTlsHandshake(initialChunk)) { + 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. @@ -809,6 +782,9 @@ export class PortProxy { config.domains.some(d => plugins.minimatch(serverName, d)) ) : undefined); + // Save domain config in connection record + connectionRecord.domainConfig = domainConfig; + // IP validation is skipped if allowedIPs is empty if (domainConfig) { const effectiveAllowedIPs: string[] = [ @@ -847,9 +823,13 @@ export class PortProxy { // Track bytes received connectionRecord.bytesReceived += chunk.length; - // Detect protocol even during connection setup - if (this.settings.enableProtocolDetection && connectionRecord.protocolType === 'unknown') { - this.detectProtocol(chunk, connectionRecord); + // Check for TLS handshake + if (!connectionRecord.isTLS && isTlsHandshake(chunk)) { + connectionRecord.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 @@ -888,6 +868,20 @@ export class PortProxy { targetSocket.setKeepAlive(true, this.settings.keepAliveInitialDelay); } + // Apply enhanced TCP options if available + if (this.settings.enableKeepAliveProbes) { + try { + if ('setKeepAliveProbes' in targetSocket) { + (targetSocket as any).setKeepAliveProbes(10); + } + if ('setKeepAliveInterval' in targetSocket) { + (targetSocket as any).setKeepAliveInterval(1000); + } + } catch (err) { + // Ignore errors - these are optional enhancements + } + } + // Setup specific error handler for connection phase targetSocket.once('error', (err) => { // This handler runs only once during the initial connection phase @@ -928,7 +922,7 @@ export class PortProxy { // Handle timeouts socket.on('timeout', () => { - console.log(`[${connectionId}] Timeout on incoming side from ${remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 300000)}`); + console.log(`[${connectionId}] Timeout on incoming side from ${remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}`); if (incomingTerminationReason === null) { incomingTerminationReason = 'timeout'; this.incrementTerminationStat('incoming', 'timeout'); @@ -937,7 +931,7 @@ export class PortProxy { }); targetSocket.on('timeout', () => { - console.log(`[${connectionId}] Timeout on outgoing side from ${remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 300000)}`); + console.log(`[${connectionId}] Timeout on outgoing side from ${remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}`); if (outgoingTerminationReason === null) { outgoingTerminationReason = 'timeout'; this.incrementTerminationStat('outgoing', 'timeout'); @@ -946,8 +940,8 @@ export class PortProxy { }); // Set appropriate timeouts using the configured value - socket.setTimeout(this.settings.socketTimeout || 300000); - targetSocket.setTimeout(this.settings.socketTimeout || 300000); + socket.setTimeout(this.settings.socketTimeout || 3600000); + targetSocket.setTimeout(this.settings.socketTimeout || 3600000); // Track outgoing data for bytes counting targetSocket.on('data', (chunk: Buffer) => { @@ -984,7 +978,7 @@ export class PortProxy { console.log( `[${connectionId}] Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` + `${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}` + - ` Protocol: ${connectionRecord.protocolType}` + ` TLS: ${connectionRecord.isTLS ? 'Yes' : 'No'}` ); } else { console.log( @@ -1003,7 +997,7 @@ export class PortProxy { console.log( `[${connectionId}] Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` + `${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}` + - ` Protocol: ${connectionRecord.protocolType}` + ` TLS: ${connectionRecord.isTLS ? 'Yes' : 'No'}` ); } else { console.log( @@ -1023,7 +1017,7 @@ export class PortProxy { if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) { try { // Try to extract SNI from potential renegotiation - const newSNI = extractSNI(renegChunk); + const newSNI = extractSNI(renegChunk, this.settings.enableTlsDebugLogging); if (newSNI && newSNI !== connectionRecord.lockedDomain) { console.log(`[${connectionId}] Rehandshake detected with different SNI: ${newSNI} vs locked ${connectionRecord.lockedDomain}. Terminating connection.`); initiateCleanupOnce('sni_mismatch'); @@ -1037,17 +1031,31 @@ export class PortProxy { }); } - // Set protocol-specific timeout based on detected protocol + // Set connection timeout if (connectionRecord.cleanupTimer) { clearTimeout(connectionRecord.cleanupTimer); } - // Set timeout based on protocol - const protocolTimeout = this.getProtocolTimeout(connectionRecord, domainConfig); + // Set timeout based on domain config or default + const connectionTimeout = this.getConnectionTimeout(connectionRecord); connectionRecord.cleanupTimer = setTimeout(() => { - console.log(`[${connectionId}] ${connectionRecord.protocolType} connection exceeded max lifetime (${plugins.prettyMs(protocolTimeout)}), forcing cleanup.`); - initiateCleanupOnce(`${connectionRecord.protocolType}_max_lifetime`); - }, protocolTimeout); + console.log(`[${connectionId}] Connection from ${remoteIP} exceeded max lifetime (${plugins.prettyMs(connectionTimeout)}), forcing cleanup.`); + initiateCleanupOnce('connection_timeout'); + }, connectionTimeout); + + // Make sure timeout doesn't keep the process alive + if (connectionRecord.cleanupTimer.unref) { + connectionRecord.cleanupTimer.unref(); + } + + // Mark TLS handshake as complete for TLS connections + if (connectionRecord.isTLS) { + connectionRecord.tlsHandshakeComplete = true; + + if (this.settings.enableTlsDebugLogging) { + console.log(`[${connectionId}] TLS handshake complete for connection from ${remoteIP}`); + } + } }); }; @@ -1055,7 +1063,7 @@ export class PortProxy { // Only apply port-based rules if the incoming port is within one of the global port ranges. if (this.settings.globalPortRanges && isPortInRanges(localPort, this.settings.globalPortRanges)) { if (this.settings.forwardAllGlobalRanges) { - if (this.settings.defaultAllowedIPs && !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) { + 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; @@ -1111,7 +1119,20 @@ export class PortProxy { } initialDataReceived = true; - const serverName = extractSNI(chunk) || ''; + + // Try to extract SNI + let serverName = ''; + + if (isTlsHandshake(chunk)) { + connectionRecord.isTLS = true; + + if (this.settings.enableTlsDebugLogging) { + console.log(`[${connectionId}] Extracting SNI from TLS handshake, ${chunk.length} bytes`); + } + + serverName = extractSNI(chunk, this.settings.enableTlsDebugLogging) || ''; + } + // Lock the connection to the negotiated SNI. connectionRecord.lockedDomain = serverName; @@ -1123,9 +1144,12 @@ export class PortProxy { }); } else { initialDataReceived = true; - if (!this.settings.defaultAllowedIPs || this.settings.defaultAllowedIPs.length === 0 || !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) { + 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(''); } }; @@ -1167,11 +1191,10 @@ export class PortProxy { const now = Date.now(); let maxIncoming = 0; let maxOutgoing = 0; - let httpConnections = 0; - let wsConnections = 0; let tlsConnections = 0; - let unknownConnections = 0; - let pooledConnections = 0; + let nonTlsConnections = 0; + let completedTlsHandshakes = 0; + let pendingTlsHandshakes = 0; // Create a copy of the keys to avoid modification during iteration const connectionIds = [...this.connectionRecords.keys()]; @@ -1180,17 +1203,16 @@ export class PortProxy { const record = this.connectionRecords.get(id); if (!record) continue; - // Track connection stats by protocol - switch (record.protocolType) { - case 'http': httpConnections++; break; - case 'websocket': wsConnections++; break; - case 'tls': - case 'https': tlsConnections++; break; - default: unknownConnections++; break; - } - - if (record.isPooledConnection) { - pooledConnections++; + // Track connection stats + if (record.isTLS) { + tlsConnections++; + if (record.tlsHandshakeComplete) { + completedTlsHandshakes++; + } else { + pendingTlsHandshakes++; + } + } else { + nonTlsConnections++; } maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime); @@ -1208,23 +1230,20 @@ export class PortProxy { 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 if (!this.settings.disableInactivityCheck) { - // Inactivity check - use protocol-specific values - let inactivityThreshold = Math.floor(Math.random() * (1800000 - 1200000 + 1)) + 1200000; // random between 20 and 30 minutes - - // Set protocol-specific inactivity thresholds - if (record.protocolType === 'http' && record.isPooledConnection) { - inactivityThreshold = this.settings.httpKeepAliveTimeout || 1200000; // 20 minutes for pooled HTTP - } else if (record.protocolType === 'websocket') { - inactivityThreshold = this.settings.wsConnectionTimeout || 14400000; // 4 hours for WebSocket - } else if (record.protocolType === 'http') { - inactivityThreshold = this.settings.httpConnectionTimeout || 1800000; // 30 minutes for HTTP - } + // Inactivity check with configurable timeout + const inactivityThreshold = this.settings.inactivityTimeout!; const inactivityTime = now - record.lastActivity; if (inactivityTime > inactivityThreshold && !record.connectionClosed) { - console.log(`[${id}] Inactivity check: No activity on ${record.protocolType} connection from ${record.remoteIP} for ${plugins.prettyMs(inactivityTime)}.`); + console.log(`[${id}] Inactivity check: No activity on connection from ${record.remoteIP} for ${plugins.prettyMs(inactivityTime)}.`); this.cleanupConnection(record, 'inactivity'); } } @@ -1233,11 +1252,11 @@ export class PortProxy { // Log detailed stats periodically console.log( `Active connections: ${this.connectionRecords.size}. ` + - `Types: HTTP=${httpConnections}, WS=${wsConnections}, TLS=${tlsConnections}, Unknown=${unknownConnections}, Pooled=${pooledConnections}. ` + + `Types: TLS=${tlsConnections} (Completed=${completedTlsHandshakes}, Pending=${pendingTlsHandshakes}), Non-TLS=${nonTlsConnections}. ` + `Longest running: IN=${plugins.prettyMs(maxIncoming)}, OUT=${plugins.prettyMs(maxOutgoing)}. ` + `Termination stats: ${JSON.stringify({IN: this.terminationStats.incoming, OUT: this.terminationStats.outgoing})}` ); - }, this.settings.inactivityCheckInterval || 30000); + }, this.settings.inactivityCheckInterval || 60000); // Make sure the interval doesn't keep the process alive if (this.connectionLogger.unref) {