diff --git a/changelog.md b/changelog.md index 5fd3ac4..adf143f 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2025-03-11 - 3.32.2 - fix(PortProxy) +Simplify TLS handshake SNI extraction and update timeout settings in PortProxy for improved maintainability and reliability. + +- Removed legacy and deprecated fields related to chained proxy configurations (isChainedProxy, chainPosition, aggressiveTlsRefresh). +- Refactored the extractSNI functions to use a simpler, more robust approach for TLS ClientHello processing. +- Adjusted default timeout and keep-alive settings to more standard values (e.g. initialDataTimeout set to 60s, socketTimeout to 1h). +- Eliminated redundant TLS session cache and deep TLS refresh logic. +- Improved logging and error handling during connection setup and renegotiation phases. + ## 2025-03-11 - 3.32.1 - fix(portproxy) Relax TLS handshake and connection timeout settings for improved stability in chained proxy scenarios; update TLS session cache defaults and add keep-alive flags to connection records. diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 729b3ab..e512f44 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.32.1', + version: '3.32.2', 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 9ddd8da..d179f93 100644 --- a/ts/classes.portproxy.ts +++ b/ts/classes.portproxy.ts @@ -10,18 +10,13 @@ export interface IDomainConfig { 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. - */ +/** Port proxy settings including global allowed port ranges */ export interface IPortProxySettings extends plugins.tls.TlsOptions { fromPort: number; toPort: number; @@ -32,10 +27,14 @@ export interface IPortProxySettings extends plugins.tls.TlsOptions { defaultBlockedIPs?: string[]; preserveSourceIP?: boolean; - // Simplified timeout settings + // Timeout settings + initialDataTimeout?: number; // Timeout for initial data/SNI (ms), default: 60000 (60s) + socketTimeout?: number; // Socket inactivity timeout (ms), default: 3600000 (1h) + inactivityCheckInterval?: number; // How often to check for inactive connections (ms), default: 60000 (60s) + maxConnectionLifetime?: number; // Default max connection lifetime (ms), default: 86400000 (24h) + inactivityTimeout?: number; // Inactivity timeout (ms), default: 14400000 (4h) + gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown - - // 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 @@ -45,7 +44,9 @@ export interface IPortProxySettings extends plugins.tls.TlsOptions { keepAliveInitialDelay?: number; // Initial delay before sending keepalive probes (ms) maxPendingDataSize?: number; // Maximum bytes to buffer during connection setup - // Logging settings + // 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 @@ -53,22 +54,14 @@ export interface IPortProxySettings extends plugins.tls.TlsOptions { // 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 + + // Enhanced keep-alive settings + keepAliveTreatment?: 'standard' | 'extended' | 'immortal'; // How to treat keep-alive connections + keepAliveInactivityMultiplier?: number; // Multiplier for inactivity timeout for keep-alive connections + extendedKeepAliveLifetime?: number; // Extended lifetime for keep-alive connections (ms) + + // New property for 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) - }; } /** @@ -97,810 +90,182 @@ interface IConnectionRecord { 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. + * @returns The server name if found, otherwise undefined. */ -function extractSNIInfo(buffer: Buffer, enableLogging: boolean = false): ISNIExtractResult | undefined { +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 { - isResumption: false, - partialExtract: true // Indicating we need more data - }; + return undefined; } - // Check first record type (has to be handshake - 22) + // 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; } - // Track multiple records and total records examined - let recordsExamined = 0; - let multipleRecords = false; - let currentPosition = 0; - let result: ISNIExtractResult | 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}`); - // 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 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; } - } - - // 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}`); + + 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; } - - // 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; + + 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; - } - - // Move to the next record - currentPosition += 5 + recordLength; - - // Set the flag if we've processed multiple records - if (currentPosition < buffer.length) { - multipleRecords = true; + } else { + offset += extensionLength; } } - - // If we processed records but didn't find SNI or session info - if (recordsExamined > 0 && !result) { - return { - isResumption: false, - recordsExamined, - multipleRecords - }; - } - - return result; + + if (enableLogging) console.log('No SNI extension found'); + return undefined; } 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); @@ -963,22 +328,7 @@ const randomizeTimeout = (baseTimeout: number, variationPercent: number = 5): nu 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; - }; - + settings: IPortProxySettings; private connectionRecords: Map = new Map(); private connectionLogger: NodeJS.Timeout | null = null; private isShuttingDown: boolean = false; @@ -998,116 +348,51 @@ export class PortProxy { // 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 + // Set reasonable defaults for all settings 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, + targetIP: settingsArg.targetIP || 'localhost', - // 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 + // Timeout settings with reasonable defaults + initialDataTimeout: settingsArg.initialDataTimeout || 60000, // 60 seconds for initial handshake + socketTimeout: ensureSafeTimeout(settingsArg.socketTimeout || 3600000), // 1 hour socket timeout + inactivityCheckInterval: settingsArg.inactivityCheckInterval || 60000, // 60 seconds interval + maxConnectionLifetime: ensureSafeTimeout(settingsArg.maxConnectionLifetime || 86400000), // 24 hours default + inactivityTimeout: ensureSafeTimeout(settingsArg.inactivityTimeout || 14400000), // 4 hours inactivity timeout + + gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000, // 30 seconds // Socket optimization settings noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true, keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true, - keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 30000, // 30 seconds (increased) - maxPendingDataSize: settingsArg.maxPendingDataSize || 20 * 1024 * 1024, // 20MB to handle large TLS handshakes + keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 10000, // 10 seconds (reduced for responsiveness) + maxPendingDataSize: settingsArg.maxPendingDataSize || 10 * 1024 * 1024, // 10MB to handle large TLS handshakes - // Feature flags - simplified with sensible defaults - disableInactivityCheck: false, // Still enable inactivity checks - enableKeepAliveProbes: true, // Still enable keep-alive probes + // Feature flags + disableInactivityCheck: settingsArg.disableInactivityCheck || false, + enableKeepAliveProbes: settingsArg.enableKeepAliveProbes !== undefined + ? settingsArg.enableKeepAliveProbes : true, // Enable by default enableDetailedLogging: settingsArg.enableDetailedLogging || false, enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false, - enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false, + enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false, // Disable randomization by default // 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 + maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100, // 100 connections per IP + connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300, // 300 per minute + + // Enhanced keep-alive settings + keepAliveTreatment: settingsArg.keepAliveTreatment || 'extended', // Extended by default + keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6, // 6x normal inactivity timeout + extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000, // 7 days }; - + // 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)}`); - } } /** @@ -1128,93 +413,58 @@ export class PortProxy { serverName?: string ): void { // Determine which NetworkProxy to use - const proxyIndex = - domainConfig.networkProxyIndex !== undefined ? domainConfig.networkProxyIndex : 0; - + 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.` - ); + 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 - ); + 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 + + // Create a connection to the NetworkProxy 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 + port: proxyPort }); - + // 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) { @@ -1222,63 +472,18 @@ export class PortProxy { } this.cleanupConnection(record, 'network_proxy_closed'); }); - + socket.on('close', () => { if (this.settings.enableDetailedLogging) { - console.log( - `[${connectionId}] Client connection closed after forwarding to NetworkProxy` - ); + 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 + // Update activity on data transfer + socket.on('data', () => this.updateActivity(record)); 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}]` @@ -1286,7 +491,7 @@ export class PortProxy { } }); } - + /** * Sets up a direct connection to the target (original behavior) * This is used when NetworkProxy isn't configured or as a fallback @@ -1300,37 +505,15 @@ export class PortProxy { 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(); @@ -1371,22 +554,11 @@ export class PortProxy { // 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 + // Add initial chunk to pending data if present 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`); + record.bytesReceived += initialChunk.length; + record.pendingData.push(Buffer.from(initialChunk)); + record.pendingDataSize = initialChunk.length; } // Create the target socket but don't set up piping immediately @@ -1396,77 +568,30 @@ export class PortProxy { // 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; + targetSocket.setKeepAlive(true, this.settings.keepAliveInitialDelay); - 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 + (targetSocket as any).setKeepAliveProbes(10); } if ('setKeepAliveInterval' in targetSocket) { - // Use a shorter interval on outgoing for more reliable detection - (targetSocket as any).setKeepAliveInterval(800); // Slightly faster than incoming + (targetSocket as any).setKeepAliveInterval(1000); } - - 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}` - ); + 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 + // Setup specific error handler for connection phase targetSocket.once('error', (err) => { // This handler runs only once during the initial connection phase const code = (err as any).code; @@ -1477,7 +602,6 @@ export class PortProxy { // 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` @@ -1493,28 +617,6 @@ export class PortProxy { } 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'); @@ -1540,21 +642,19 @@ export class PortProxy { // 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( + `[${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)}` + `[${connectionId}] Timeout on incoming side from ${record.remoteIP} after ${plugins.prettyMs( + this.settings.socketTimeout || 3600000 + )}` ); if (record.incomingTerminationReason === null) { record.incomingTerminationReason = 'timeout'; @@ -1567,21 +667,19 @@ export class PortProxy { // 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( + `[${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)}` + `[${connectionId}] Timeout on outgoing side from ${record.remoteIP} after ${plugins.prettyMs( + this.settings.socketTimeout || 3600000 + )}` ); if (record.outgoingTerminationReason === null) { record.outgoingTerminationReason = 'timeout'; @@ -1595,11 +693,9 @@ export class PortProxy { // 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` - ); + console.log(`[${connectionId}] Disabled socket timeouts for immortal keep-alive connection`); } } else { // Set normal timeouts for other connections @@ -1627,153 +723,14 @@ export class PortProxy { // 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}`); + 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); @@ -1789,9 +746,7 @@ export class PortProxy { ? ` (Port-based for domain: ${domainConfig.domains.join(', ')})` : '' }` + - ` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${ - record.hasKeepAlive ? 'Yes' : 'No' - }` + ` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${record.hasKeepAlive ? 'Yes' : 'No'}` ); } else { console.log( @@ -1807,173 +762,7 @@ export class PortProxy { } }); } 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 + // No pending data, so just set up piping socket.pipe(targetSocket); targetSocket.pipe(socket); socket.resume(); // Resume the socket after piping is established @@ -1988,9 +777,7 @@ export class PortProxy { ? ` (Port-based for domain: ${domainConfig.domains.join(', ')})` : '' }` + - ` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${ - record.hasKeepAlive ? 'Yes' : 'No' - }` + ` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${record.hasKeepAlive ? 'Yes' : 'No'}` ); } else { console.log( @@ -2010,98 +797,82 @@ export class PortProxy { record.pendingData = []; record.pendingDataSize = 0; - // Renegotiation detection is now handled before piping is established - // This ensures the data listener receives all packets properly + // Add the renegotiation listener for SNI validation + if (serverName) { + socket.on('data', (renegChunk: Buffer) => { + if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) { + try { + // Try to extract SNI from potential renegotiation + const newSNI = extractSNI(renegChunk, this.settings.enableTlsDebugLogging); + if (newSNI && newSNI !== record.lockedDomain) { + console.log( + `[${connectionId}] Rehandshake detected with different SNI: ${newSNI} vs locked ${record.lockedDomain}. Terminating connection.` + ); + this.initiateCleanupOnce(record, 'sni_mismatch'); + } else if (newSNI && this.settings.enableDetailedLogging) { + console.log( + `[${connectionId}] Rehandshake detected with same SNI: ${newSNI}. Allowing.` + ); + } + } catch (err) { + console.log( + `[${connectionId}] Error processing potential renegotiation: ${err}. Allowing connection to continue.` + ); + } + } + }); + } // Set 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` - ); + 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.` + `[${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 - )}` - ); + 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 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.` + `[${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(); @@ -2179,220 +950,6 @@ export class PortProxy { 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. @@ -2490,9 +1047,7 @@ export class PortProxy { ` Duration: ${plugins.prettyMs( duration )}, Bytes IN: ${bytesReceived}, OUT: ${bytesSent}, ` + - `TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${ - record.hasKeepAlive ? 'Yes' : 'No' - }` + + `TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${record.hasKeepAlive ? 'Yes' : 'No'}` + `${record.usingNetworkProxy ? `, NetworkProxy: ${record.networkProxyIndex}` : ''}` ); } else { @@ -2503,6 +1058,18 @@ export class PortProxy { } } + /** + * Update connection activity timestamp + */ + private updateActivity(record: IConnectionRecord): void { + record.lastActivity = Date.now(); + + // Clear any inactivity warning + if (record.inactivityWarningIssued) { + record.inactivityWarningIssued = false; + } + } + /** * Get target IP with round-robin support */ @@ -2515,7 +1082,7 @@ export class PortProxy { } return this.settings.targetIP!; } - + /** * Initiates cleanup once for a connection */ @@ -2523,15 +1090,12 @@ export class PortProxy { if (this.settings.enableDetailedLogging) { console.log(`[${record.id}] Connection cleanup initiated for ${record.remoteIP} (${reason})`); } - - if ( - record.incomingTerminationReason === null || - record.incomingTerminationReason === undefined - ) { + + if (record.incomingTerminationReason === null || record.incomingTerminationReason === undefined) { record.incomingTerminationReason = reason; this.incrementTerminationStat('incoming', reason); } - + this.cleanupConnection(record, reason); } @@ -2655,7 +1219,7 @@ export class PortProxy { // Apply socket optimizations socket.setNoDelay(this.settings.noDelay); - + // Create a unique connection ID and record const connectionId = generateConnectionId(); const connectionRecord: IConnectionRecord = { @@ -2679,27 +1243,16 @@ export class PortProxy { 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, + // Initialize NetworkProxy tracking fields + usingNetworkProxy: 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 { @@ -2710,14 +1263,10 @@ export class PortProxy { 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}` - ); + console.log(`[${connectionId}] Enhanced TCP keep-alive settings not supported: ${err}`); } } } @@ -2730,8 +1279,8 @@ export class PortProxy { 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}` + `Keep-Alive: ${connectionRecord.hasKeepAlive ? 'Enabled' : 'Disabled'}. ` + + `Active connections: ${this.connectionRecords.size}` ); } else { console.log( @@ -2835,95 +1384,16 @@ export class PortProxy { } // If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup. - let domainConfig = forcedDomain + const 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) { @@ -2948,12 +1418,12 @@ export class PortProxy { )}` ); } - + // Check if we should forward this to a NetworkProxy if ( - isTlsHandshakeDetected && - domainConfig.useNetworkProxy === true && - initialChunk && + isTlsHandshakeDetected && + domainConfig.useNetworkProxy === true && + initialChunk && this.networkProxies.length > 0 ) { return this.forwardToNetworkProxy( @@ -3080,49 +1550,19 @@ export class PortProxy { initialDataReceived = true; - // Try to extract SNI - with enhanced logging for troubleshooting + // Try to extract SNI 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`); + serverName = extractSNI(chunk, this.settings.enableTlsDebugLogging) || ''; } // Lock the connection to the negotiated SNI. @@ -3221,11 +1661,11 @@ export class PortProxy { } else { nonTlsConnections++; } - + if (record.hasKeepAlive) { keepAliveConnections++; } - + if (record.usingNetworkProxy) { networkProxyConnections++; } @@ -3266,80 +1706,35 @@ export class PortProxy { } // Skip inactivity check if disabled or for immortal keep-alive connections - if ( - !this.settings.disableInactivityCheck && - !(record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') - ) { + 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.` + `[${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`); } @@ -3348,48 +1743,18 @@ export class PortProxy { } } } 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'); - } + // 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` - ); + console.log(`[${id}] Connection activity detected after inactivity warning, resetting warning`); } record.inactivityWarningIssued = false; } @@ -3438,9 +1803,6 @@ export class PortProxy { 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( @@ -3541,4 +1903,4 @@ export class PortProxy { console.log('PortProxy shutdown complete.'); } -} +} \ No newline at end of file