diff --git a/changelog.md b/changelog.md index 1d73ed9..7e97339 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2025-03-11 - 3.32.0 - feat(PortProxy) +Enhance TLS session cache, SNI extraction, and chained proxy support in PortProxy. Improve handling of multiple and fragmented TLS records, and add new configuration options (isChainedProxy, chainPosition, aggressiveTlsRefresh, tlsSessionCache) for robust TLS certificate refresh. + +- Implement TlsSessionCache with configurable cleanup, eviction, and statistics. +- Improve extractSNIInfo to process multiple TLS records and partial handshake data. +- Add new settings to detect chained proxy scenarios and adjust timeouts accordingly. +- Enhance TLS state refresh with aggressive probing and deep refresh sequence. + ## 2025-03-11 - 3.31.2 - fix(PortProxy) Improve SNI renegotiation handling by adding flexible domain configuration matching on rehandshake and session resumption events. diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index a693a2a..66c183c 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.31.2', + version: '3.32.0', 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 adc9ef1..757d703 100644 --- a/ts/classes.portproxy.ts +++ b/ts/classes.portproxy.ts @@ -56,6 +56,19 @@ export interface IPortProxySettings extends plugins.tls.TlsOptions { // 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) + }; } /** @@ -108,49 +121,254 @@ interface ITlsSessionInfo { 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 } -// Global cache of TLS session IDs to SNI domains -// This ensures resumed sessions maintain their SNI binding -const tlsSessionCache = new Map(); +/** + * 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 +} -// Reference to session cleanup timer so we can clear it -let tlsSessionCleanupTimer: NodeJS.Timeout | null = null; +// Default configuration for session cache +const DEFAULT_SESSION_CACHE_CONFIG: ITlsSessionCacheConfig = { + maxEntries: 10000, // Default max 10,000 entries + expiryTime: 24 * 60 * 60 * 1000, // 24 hours default + cleanupInterval: 10 * 60 * 1000, // Clean up every 10 minutes + enabled: true // Enabled by default +}; -// Start the cleanup timer for session cache -function startSessionCleanupTimer() { - // Avoid creating multiple timers - if (tlsSessionCleanupTimer) { - clearInterval(tlsSessionCleanupTimer); +// 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(); } - - // Create new cleanup timer - tlsSessionCleanupTimer = setInterval(() => { - const now = Date.now(); - const expiryTime = 24 * 60 * 60 * 1000; // 24 hours + + /** + * 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); - for (const [sessionId, info] of tlsSessionCache.entries()) { - if (now - info.ticketTimestamp > expiryTime) { - tlsSessionCache.delete(sessionId); + 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(); } } - }, 60 * 60 * 1000); // Clean up once per hour - - // Make sure the interval doesn't keep the process alive - if (tlsSessionCleanupTimer.unref) { - tlsSessionCleanupTimer.unref(); + } + + /** + * 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; + } } } -// Start the timer initially -startSessionCleanupTimer(); +// Create the global session cache +const tlsSessionCache = new TlsSessionCache(); -// Function to stop the cleanup timer (used during shutdown) +// Legacy function for backward compatibility function stopSessionCleanupTimer() { - if (tlsSessionCleanupTimer) { - clearInterval(tlsSessionCleanupTimer); - tlsSessionCleanupTimer = null; - } + tlsSessionCache.stop(); } /** @@ -165,6 +383,8 @@ interface ISNIExtractResult { 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 } /** @@ -172,6 +392,11 @@ interface ISNIExtractResult { * 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. @@ -181,49 +406,159 @@ function extractSNIInfo(buffer: Buffer, enableLogging: boolean = false): ISNIExt // Check if buffer is too small for TLS if (buffer.length < 5) { if (enableLogging) console.log('Buffer too small for TLS header'); - return undefined; + return { + isResumption: false, + partialExtract: true // Indicating we need more data + }; } - // Check record type (has to be handshake - 22) + // Check first record type (has to be handshake - 22) const recordType = buffer.readUInt8(0); if (recordType !== 22) { if (enableLogging) console.log(`Not a TLS handshake. Record type: ${recordType}`); return undefined; } - // 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}`); + // Track multiple records and total records examined + let recordsExamined = 0; + let multipleRecords = false; + let currentPosition = 0; + let result: ISNIExtractResult | undefined; - // 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; + // Process potentially multiple TLS records in the buffer + while (currentPosition + 5 <= buffer.length) { + recordsExamined++; + + // Read record header + const currentRecordType = buffer.readUInt8(currentPosition); + + // Only process handshake records (type 22) + if (currentRecordType !== 22) { + if (enableLogging) console.log(`Skipping non-handshake record at position ${currentPosition}, type: ${currentRecordType}`); + + // Move to next potential record + if (currentPosition + 5 <= buffer.length) { + // Need at least 5 bytes to determine next record length + const nextRecordLength = buffer.readUInt16BE(currentPosition + 3); + currentPosition += 5 + nextRecordLength; + multipleRecords = true; + continue; + } else { + // Not enough data to determine next record + break; + } + } + + // Check TLS version + const majorVersion = buffer.readUInt8(currentPosition + 1); + const minorVersion = buffer.readUInt8(currentPosition + 2); + if (enableLogging) console.log(`TLS Version: ${majorVersion}.${minorVersion} at position ${currentPosition}`); + + // Get record length + const recordLength = buffer.readUInt16BE(currentPosition + 3); + + // Check if we have the complete record + if (currentPosition + 5 + recordLength > buffer.length) { + if (enableLogging) { + console.log(`Incomplete TLS record at position ${currentPosition}. Expected: ${currentPosition + 5 + recordLength}, Got: ${buffer.length}`); + } + + // Return partial info and signal that more data is needed + return { + isResumption: false, + partialExtract: true, + recordsExamined, + multipleRecords + }; + } + + // Process this record - extract handshake information + const recordResult = extractSNIFromRecord( + buffer.slice(currentPosition, currentPosition + 5 + recordLength), + enableLogging + ); + + // If we found SNI or session info in this record, store it + if (recordResult && (recordResult.serverName || recordResult.isResumption)) { + result = recordResult; + result.recordsExamined = recordsExamined; + result.multipleRecords = multipleRecords; + + // Once we've found SNI or session resumption info, we can stop processing + // But we'll still set the multipleRecords flag to indicate more records exist + if (currentPosition + 5 + recordLength < buffer.length) { + result.multipleRecords = true; + } + + break; + } + + // Move to the next record + currentPosition += 5 + recordLength; + + // Set the flag if we've processed multiple records + if (currentPosition < buffer.length) { + multipleRecords = true; + } } + + // If we processed records but didn't find SNI or session info + if (recordsExamined > 0 && !result) { + return { + isResumption: false, + recordsExamined, + multipleRecords + }; + } + + return result; + } catch (err) { + console.log(`Error extracting SNI: ${err}`); + return undefined; + } +} +/** + * Extracts SNI information from a single TLS record + * This helper function processes a single complete TLS record + */ +function extractSNIFromRecord(recordBuffer: Buffer, enableLogging: boolean = false): ISNIExtractResult | undefined { + try { + // Skip the 5-byte TLS record header let offset = 5; - const handshakeType = buffer.readUInt8(offset); - if (handshakeType !== 1) { + + // 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; } - - offset += 4; // Skip handshake header (type + length) - + + // 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 = buffer.readUInt8(offset); - const clientMinorVersion = buffer.readUInt8(offset + 1); + const clientMajorVersion = recordBuffer.readUInt8(offset); + const clientMinorVersion = recordBuffer.readUInt8(offset + 1); if (enableLogging) console.log(`Client Version: ${clientMajorVersion}.${clientMinorVersion}`); - - offset += 2 + 32; // Skip client version and random - + + // 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 = buffer.readUInt8(offset); + const sessionIDLength = recordBuffer.readUInt8(offset); if (enableLogging) console.log(`Session ID Length: ${sessionIDLength}`); // If there's a session ID, extract it @@ -233,7 +568,12 @@ function extractSNIInfo(buffer: Buffer, enableLogging: boolean = false): ISNIExt let resumedDomain: string | undefined; if (sessionIDLength > 0) { - sessionId = Buffer.from(buffer.slice(offset + 1, offset + 1 + sessionIDLength)); + 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'); @@ -254,56 +594,121 @@ function extractSNIInfo(buffer: Buffer, enableLogging: boolean = false): ISNIExt } } - offset += 1 + sessionIDLength; // Skip session ID - + offset += 1 + sessionIDLength; // Skip session ID length and data + // Cipher suites - if (offset + 2 > buffer.length) { + if (offset + 2 > recordBuffer.length) { if (enableLogging) console.log('Buffer too small for cipher suites length'); return undefined; } - const cipherSuitesLength = buffer.readUInt16BE(offset); + + const cipherSuitesLength = recordBuffer.readUInt16BE(offset); if (enableLogging) console.log(`Cipher Suites Length: ${cipherSuitesLength}`); - offset += 2 + cipherSuitesLength; // Skip cipher suites - + + 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 > buffer.length) { + if (offset + 1 > recordBuffer.length) { if (enableLogging) console.log('Buffer too small for compression methods length'); return undefined; } - const compressionMethodsLength = buffer.readUInt8(offset); + + const compressionMethodsLength = recordBuffer.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'); + + if (offset + 1 + compressionMethodsLength > recordBuffer.length) { + if (enableLogging) console.log('Buffer too small for compression methods data'); return undefined; } - const extensionsLength = buffer.readUInt16BE(offset); + + 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 > buffer.length) { - if (enableLogging) - console.log( - `Buffer too small for extensions. Expected end: ${extensionsEnd}, Buffer length: ${buffer.length}` - ); - return undefined; + + 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 = buffer.readUInt16BE(offset); - const extensionLength = buffer.readUInt16BE(offset + 2); - - if (enableLogging) + 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) @@ -312,7 +717,7 @@ function extractSNIInfo(buffer: Buffer, enableLogging: boolean = false): ISNIExt // Extract a hash of the ticket for tracking if (extensionLength > 16) { // Ensure we have enough bytes to create a meaningful ID - const ticketBytes = buffer.slice(offset, offset + Math.min(16, extensionLength)); + const ticketBytes = recordBuffer.slice(offset, offset + Math.min(16, extensionLength)); sessionTicketId = ticketBytes.toString('hex'); if (enableLogging) { @@ -332,47 +737,74 @@ function extractSNIInfo(buffer: Buffer, enableLogging: boolean = false): ISNIExt } } } - + + // Server Name Indication extension (type 0x0000) if (extensionType === 0x0000) { - // SNI extension - if (offset + 2 > buffer.length) { + if (offset + 2 > recordBuffer.length) { if (enableLogging) console.log('Buffer too small for SNI list length'); - return undefined; + return { + isResumption, + sessionId, + sessionIdKey, + hasSessionTicket, + partialExtract: true + }; } - - const sniListLength = buffer.readUInt16BE(offset); + + const sniListLength = recordBuffer.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; + + 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 = buffer.readUInt8(offset++); - const nameLen = buffer.readUInt16BE(offset); + 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) { - // host_name - if (offset + nameLen > buffer.length) { - if (enableLogging) - console.log( - `Buffer too small for hostname. Expected: ${offset + nameLen}, Got: ${ - buffer.length - }` - ); - return undefined; + 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 = buffer.toString('utf8', offset, offset + nameLen); + + 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 @@ -412,15 +844,20 @@ function extractSNIInfo(buffer: Buffer, enableLogging: boolean = false): ISNIExt 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 @@ -446,7 +883,7 @@ function extractSNIInfo(buffer: Buffer, enableLogging: boolean = false): ISNIExt resumedDomain }; } catch (err) { - console.log(`Error extracting SNI: ${err}`); + console.log(`Error in extractSNIFromRecord: ${err}`); return undefined; } } @@ -564,17 +1001,71 @@ export class PortProxy { private networkProxies: NetworkProxy[] = []; constructor(settingsArg: IPortProxySettings) { - // Set hardcoded sensible defaults for all settings + // 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 + let socketTimeout = 1800000; // 30 minutes default + + if (isChainedProxy) { + // Use shorter timeouts for chained proxies to prevent certificate issues + const chainPosition = settingsArg.chainPosition || 'middle'; + + // Adjust timeouts based on position in chain + switch (chainPosition) { + case 'first': + // First proxy can be a bit more lenient as it handles browser connections + socketTimeout = 1500000; // 25 minutes + break; + case 'middle': + // Middle proxies need shorter timeouts + socketTimeout = 1200000; // 20 minutes + break; + case 'last': + // Last proxy directly connects to backend + socketTimeout = 1800000; // 30 minutes + break; + } + + console.log(`Configured as ${chainPosition} proxy in chain. Using adjusted timeouts for optimal TLS handling.`); + } + + // Set hardcoded sensible defaults for all settings with chain-aware adjustments this.settings = { ...settingsArg, - targetIP: settingsArg.targetIP || 'localhost', + targetIP: targetIP, + + // Record the chained proxy status for use in other methods + isChainedProxy: isChainedProxy, + chainPosition: settingsArg.chainPosition || (isChainedProxy ? 'middle' : 'last'), + aggressiveTlsRefresh: aggressiveTlsRefresh, // Hardcoded timeout settings optimized for TLS safety in all deployment scenarios initialDataTimeout: 60000, // 60 seconds for initial handshake - socketTimeout: 1800000, // 30 minutes - short enough for regular certificate refresh - inactivityCheckInterval: 60000, // 60 seconds interval for regular cleanup - maxConnectionLifetime: 3600000, // 1 hour maximum lifetime for all connections - inactivityTimeout: 1800000, // 30 minutes inactivity timeout + socketTimeout: socketTimeout, // Adjusted based on chain position + inactivityCheckInterval: isChainedProxy ? 30000 : 60000, // More frequent checks for chains + maxConnectionLifetime: isChainedProxy ? 2700000 : 3600000, // 45min or 1hr lifetime + inactivityTimeout: isChainedProxy ? 1200000 : 1800000, // 20min or 30min inactivity timeout gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000, // 30 seconds @@ -598,11 +1089,22 @@ export class PortProxy { // Keep-alive settings with sensible defaults that ensure certificate safety keepAliveTreatment: 'standard', // Always use standard treatment for certificate safety keepAliveInactivityMultiplier: 2, // 2x normal inactivity timeout for minimal extension - extendedKeepAliveLifetime: 3 * 60 * 60 * 1000, // 3 hours maximum (previously was 7 days!) + // Use shorter lifetime for chained proxies + extendedKeepAliveLifetime: isChainedProxy + ? 2 * 60 * 60 * 1000 // 2 hours for chained proxies + : 3 * 60 * 60 * 1000, // 3 hours for standalone proxies }; // Store NetworkProxy instances if provided this.networkProxies = settingsArg.networkProxies || []; + + // Log proxy configuration details + console.log(`PortProxy initialized with ${isChainedProxy ? 'chained proxy' : 'standalone'} configuration.`); + if (isChainedProxy) { + console.log(`TLS certificate refresh: ${aggressiveTlsRefresh ? 'Aggressive' : 'Standard'}`); + console.log(`Connection lifetime: ${plugins.prettyMs(this.settings.maxConnectionLifetime)}`); + console.log(`Inactivity timeout: ${plugins.prettyMs(this.settings.inactivityTimeout)}`); + } } /** @@ -1461,7 +1963,8 @@ export class PortProxy { } /** - * Update connection activity timestamp with sleep detection + * 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 @@ -1471,62 +1974,107 @@ export class PortProxy { if (record.lastActivity > 0) { const timeDiff = now - record.lastActivity; - // If time difference is very large (> 30 minutes) and this is a keep-alive connection, - // this might indicate system sleep rather than just inactivity - if (timeDiff > 30 * 60 * 1000 && record.hasKeepAlive) { - if (this.settings.enableDetailedLogging) { - console.log( - `[${record.id}] Detected possible system sleep for ${plugins.prettyMs(timeDiff)}. ` + - `Handling keep-alive connection after long inactivity.` - ); + // Enhanced sleep detection with graduated thresholds + // For chained proxies, we need to be more aggressive about refreshing connections + const isChainedProxy = this.settings.targetIP === 'localhost' || this.settings.targetIP === '127.0.0.1'; + const minuteInMs = 60 * 1000; + + // Different thresholds based on connection type and configuration + const shortInactivityThreshold = isChainedProxy ? 10 * minuteInMs : 15 * minuteInMs; + const mediumInactivityThreshold = isChainedProxy ? 20 * minuteInMs : 30 * minuteInMs; + const longInactivityThreshold = isChainedProxy ? 60 * minuteInMs : 120 * minuteInMs; + + // 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}`); + } + } } - - // For TLS keep-alive connections after sleep/long inactivity, force close - // to make browser establish a new connection with fresh certificate context + } + + // 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) { - // More generous timeout now that we've fixed the renegotiation handling - if (timeDiff > 2 * 60 * 60 * 1000) { - // If inactive for more than 2 hours (increased from 20 minutes) + // If in a chained proxy, we should be even more aggressive about refreshing + if (isChainedProxy) { console.log( - `[${record.id}] TLS connection inactive for ${plugins.prettyMs(timeDiff)}. ` + - `Closing to force new connection with fresh certificate.` + `[${record.id}] TLS connection in chained proxy inactive for ${plugins.prettyMs(timeDiff)}. ` + + `Closing to prevent certificate inconsistencies across chain.` ); - return this.initiateCleanupOnce(record, 'certificate_refresh_needed'); - } else if (timeDiff > 30 * 60 * 1000) { - // For shorter but still significant inactivity (30+ minutes), refresh TLS state - console.log( - `[${record.id}] TLS connection inactive for ${plugins.prettyMs(timeDiff)}. ` + - `Refreshing TLS state.` - ); - this.refreshTlsStateAfterSleep(record); - - // Add an additional check in 15 minutes if no activity - const refreshCheckId = record.id; - const refreshCheck = setTimeout(() => { - const currentRecord = this.connectionRecords.get(refreshCheckId); - if (currentRecord && Date.now() - currentRecord.lastActivity > 15 * 60 * 1000) { + 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. ` + - `Closing connection to ensure certificate freshness.` + `[${refreshCheckId}] No activity detected after TLS refresh (${plugins.prettyMs(verificationTimeDiff)}). ` + + `Closing connection to ensure proper browser reconnection.` ); this.initiateCleanupOnce(currentRecord, 'tls_refresh_verification_failed'); } - }, 15 * 60 * 1000); - - // Make sure timeout doesn't keep the process alive - if (refreshCheck.unref) { - refreshCheck.unref(); } - } else { - // For shorter inactivity periods, try to refresh the TLS state normally - this.refreshTlsStateAfterSleep(record); + }, verificationTimeout); + + // Make sure timeout doesn't keep the process alive + if (refreshCheck.unref) { + refreshCheck.unref(); } } - // Mark that we detected sleep + // 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 @@ -1539,9 +2087,11 @@ export class PortProxy { } /** - * Refresh TLS state after sleep detection + * 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 refreshTlsStateAfterSleep(record: IConnectionRecord): void { + private performDeepTlsRefresh(record: IConnectionRecord): void { // Skip if we're using a NetworkProxy as it handles its own TLS state if (record.usingNetworkProxy) { return; @@ -1554,31 +2104,76 @@ export class PortProxy { const connectionAge = Date.now() - record.incomingStartTime; const hourInMs = 60 * 60 * 1000; - // For TLS browser connections, use a more generous timeout now that - // we've fixed the renegotiation handling issues - if (record.isTLS && record.hasKeepAlive && connectionAge > 8 * hourInMs) { // 8 hours instead of 45 minutes + // 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 on browser reconnect in proxy chain.` + `Closing to ensure proper certificate handling across proxy chain.` ); - return this.initiateCleanupOnce(record, 'certificate_context_refresh'); + return this.initiateCleanupOnce(record, 'certificate_age_refresh'); } - // For newer connections, try to send a refresh packet + // 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}] Sent refresh packet after sleep detection`); + console.log(`[${record.id}] Initiated deep TLS refresh sequence`); } } } catch (err) { - console.log(`[${record.id}] Error refreshing TLS state: ${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.