diff --git a/changelog.md b/changelog.md index 39b34e7..43d55a3 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2025-03-11 - 3.31.0 - feat(PortProxy) +Improve TLS handshake SNI extraction and add session resumption tracking in PortProxy + +- Added ITlsSessionInfo interface and a global tlsSessionCache to track TLS session IDs for session resumption +- Implemented a cleanup timer for the TLS session cache with startSessionCleanupTimer and stopSessionCleanupTimer +- Enhanced extractSNIInfo to return detailed SNI information including session IDs, ticket details, and resumption status +- Updated renegotiation handlers to use extractSNIInfo for proper SNI extraction during TLS rehandshake + ## 2025-03-11 - 3.30.8 - fix(core) No changes in this commit. diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 76a097d..baf0a50 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.30.8', + version: '3.31.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 4413259..d35b2fc 100644 --- a/ts/classes.portproxy.ts +++ b/ts/classes.portproxy.ts @@ -100,14 +100,82 @@ interface IConnectionRecord { 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 +} + +// Global cache of TLS session IDs to SNI domains +// This ensures resumed sessions maintain their SNI binding +const tlsSessionCache = new Map(); + +// Reference to session cleanup timer so we can clear it +let tlsSessionCleanupTimer: NodeJS.Timeout | null = null; + +// Start the cleanup timer for session cache +function startSessionCleanupTimer() { + // Avoid creating multiple timers + if (tlsSessionCleanupTimer) { + clearInterval(tlsSessionCleanupTimer); + } + + // Create new cleanup timer + tlsSessionCleanupTimer = setInterval(() => { + const now = Date.now(); + const expiryTime = 24 * 60 * 60 * 1000; // 24 hours + + for (const [sessionId, info] of tlsSessionCache.entries()) { + if (now - info.ticketTimestamp > expiryTime) { + tlsSessionCache.delete(sessionId); + } + } + }, 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 timer initially +startSessionCleanupTimer(); + +// Function to stop the cleanup timer (used during shutdown) +function stopSessionCleanupTimer() { + if (tlsSessionCleanupTimer) { + clearInterval(tlsSessionCleanupTimer); + tlsSessionCleanupTimer = null; + } +} + +/** + * 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 +} + /** * 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. + * * @param buffer - Buffer containing the TLS ClientHello. * @param enableLogging - Whether to enable detailed logging. - * @returns The server name if found, otherwise undefined. + * @returns An object containing SNI and session information, or undefined if parsing fails. */ -function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | undefined { +function extractSNIInfo(buffer: Buffer, enableLogging: boolean = false): ISNIExtractResult | undefined { try { // Check if buffer is too small for TLS if (buffer.length < 5) { @@ -153,9 +221,38 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un offset += 2 + 32; // Skip client version and random - // Session ID + // Extract Session ID for session resumption tracking const sessionIDLength = buffer.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) { + sessionId = Buffer.from(buffer.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 // Cipher suites @@ -194,6 +291,10 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un return undefined; } + // Variables to track session tickets + let hasSessionTicket = false; + let sessionTicketId: string | undefined; + // Parse extensions while (offset + 4 <= extensionsEnd) { const extensionType = buffer.readUInt16BE(offset); @@ -203,6 +304,33 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un console.log(`Extension Type: 0x${extensionType.toString(16)}, Length: ${extensionLength}`); 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 = buffer.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; + } + } + } + } + } if (extensionType === 0x0000) { // SNI extension @@ -245,7 +373,43 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un const serverName = buffer.toString('utf8', offset, offset + nameLen); if (enableLogging) console.log(`Extracted SNI: ${serverName}`); - return 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 + }; } offset += nameLen; @@ -257,13 +421,46 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un } if (enableLogging) console.log('No SNI extension found'); - return undefined; + + // 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 extracting SNI: ${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); @@ -776,8 +973,15 @@ export class PortProxy { this.updateActivity(record); try { - // Try to extract SNI from potential renegotiation - const newSNI = extractSNI(renegChunk, this.settings.enableTlsDebugLogging); + // Extract all TLS information including session resumption data + const sniInfo = extractSNIInfo(renegChunk, this.settings.enableTlsDebugLogging); + 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, we MUST allow it through if (newSNI === undefined) { @@ -878,8 +1082,15 @@ export class PortProxy { this.updateActivity(record); try { - // Try to extract SNI from potential renegotiation - const newSNI = extractSNI(renegChunk, this.settings.enableTlsDebugLogging); + // Extract all TLS information including session resumption data + const sniInfo = extractSNIInfo(renegChunk, this.settings.enableTlsDebugLogging); + 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, we MUST allow it through if (newSNI === undefined) { @@ -1880,7 +2091,17 @@ export class PortProxy { ); } - serverName = extractSNI(chunk, this.settings.enableTlsDebugLogging) || ''; + // 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}`); + } else { + // Normal SNI extraction + serverName = sniInfo?.serverName || ''; + } } // Lock the connection to the negotiated SNI. @@ -2196,6 +2417,9 @@ 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(