feat(PortProxy): Improve TLS handshake SNI extraction and add session resumption tracking in PortProxy
This commit is contained in:
		| @@ -1,5 +1,13 @@ | |||||||
| # Changelog | # 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) | ## 2025-03-11 - 3.30.8 - fix(core) | ||||||
| No changes in this commit. | No changes in this commit. | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,6 +3,6 @@ | |||||||
|  */ |  */ | ||||||
| export const commitinfo = { | export const commitinfo = { | ||||||
|   name: '@push.rocks/smartproxy', |   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.' |   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.' | ||||||
| } | } | ||||||
|   | |||||||
| @@ -100,14 +100,82 @@ interface IConnectionRecord { | |||||||
|   lastSleepDetection?: number; // Timestamp of the last sleep detection |   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<string, ITlsSessionInfo>(); | ||||||
|  |  | ||||||
|  | // 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. |  * Extracts the SNI (Server Name Indication) from a TLS ClientHello packet. | ||||||
|  * Enhanced for robustness and detailed logging. |  * 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 buffer - Buffer containing the TLS ClientHello. | ||||||
|  * @param enableLogging - Whether to enable detailed logging. |  * @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 { |   try { | ||||||
|     // Check if buffer is too small for TLS |     // Check if buffer is too small for TLS | ||||||
|     if (buffer.length < 5) { |     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 |     offset += 2 + 32; // Skip client version and random | ||||||
|  |  | ||||||
|     // Session ID |     // Extract Session ID for session resumption tracking | ||||||
|     const sessionIDLength = buffer.readUInt8(offset); |     const sessionIDLength = buffer.readUInt8(offset); | ||||||
|     if (enableLogging) console.log(`Session ID Length: ${sessionIDLength}`); |     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 |     offset += 1 + sessionIDLength; // Skip session ID | ||||||
|  |  | ||||||
|     // Cipher suites |     // Cipher suites | ||||||
| @@ -194,6 +291,10 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un | |||||||
|       return undefined; |       return undefined; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // Variables to track session tickets | ||||||
|  |     let hasSessionTicket = false; | ||||||
|  |     let sessionTicketId: string | undefined; | ||||||
|  |      | ||||||
|     // Parse extensions |     // Parse extensions | ||||||
|     while (offset + 4 <= extensionsEnd) { |     while (offset + 4 <= extensionsEnd) { | ||||||
|       const extensionType = buffer.readUInt16BE(offset); |       const extensionType = buffer.readUInt16BE(offset); | ||||||
| @@ -204,6 +305,33 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un | |||||||
|  |  | ||||||
|       offset += 4; |       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) { |       if (extensionType === 0x0000) { | ||||||
|         // SNI extension |         // SNI extension | ||||||
|         if (offset + 2 > buffer.length) { |         if (offset + 2 > buffer.length) { | ||||||
| @@ -245,7 +373,43 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un | |||||||
|  |  | ||||||
|             const serverName = buffer.toString('utf8', offset, offset + nameLen); |             const serverName = buffer.toString('utf8', offset, offset + nameLen); | ||||||
|             if (enableLogging) console.log(`Extracted SNI: ${serverName}`); |             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; |           offset += nameLen; | ||||||
| @@ -257,13 +421,46 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (enableLogging) console.log('No SNI extension found'); |     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) { |   } catch (err) { | ||||||
|     console.log(`Error extracting SNI: ${err}`); |     console.log(`Error extracting SNI: ${err}`); | ||||||
|     return undefined; |     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 | // Helper: Check if a port falls within any of the given port ranges | ||||||
| const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }>): boolean => { | const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }>): boolean => { | ||||||
|   return ranges.some((range) => port >= range.from && port <= range.to); |   return ranges.some((range) => port >= range.from && port <= range.to); | ||||||
| @@ -776,8 +973,15 @@ export class PortProxy { | |||||||
|                 this.updateActivity(record); |                 this.updateActivity(record); | ||||||
|                  |                  | ||||||
|                 try { |                 try { | ||||||
|                   // Try to extract SNI from potential renegotiation |                   // Extract all TLS information including session resumption data | ||||||
|                   const newSNI = extractSNI(renegChunk, this.settings.enableTlsDebugLogging); |                   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 |                   // IMPORTANT: If we can't extract an SNI from renegotiation, we MUST allow it through | ||||||
|                   if (newSNI === undefined) { |                   if (newSNI === undefined) { | ||||||
| @@ -878,8 +1082,15 @@ export class PortProxy { | |||||||
|               this.updateActivity(record); |               this.updateActivity(record); | ||||||
|                |                | ||||||
|               try { |               try { | ||||||
|                 // Try to extract SNI from potential renegotiation |                 // Extract all TLS information including session resumption data | ||||||
|                 const newSNI = extractSNI(renegChunk, this.settings.enableTlsDebugLogging); |                 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 |                 // IMPORTANT: If we can't extract an SNI from renegotiation, we MUST allow it through | ||||||
|                 if (newSNI === undefined) { |                 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. |           // Lock the connection to the negotiated SNI. | ||||||
| @@ -2197,6 +2418,9 @@ export class PortProxy { | |||||||
|     console.log('PortProxy shutting down...'); |     console.log('PortProxy shutting down...'); | ||||||
|     this.isShuttingDown = true; |     this.isShuttingDown = true; | ||||||
|      |      | ||||||
|  |     // Stop the session cleanup timer | ||||||
|  |     stopSessionCleanupTimer(); | ||||||
|  |  | ||||||
|     // Stop accepting new connections |     // Stop accepting new connections | ||||||
|     const closeServerPromises: Promise<void>[] = this.netServers.map( |     const closeServerPromises: Promise<void>[] = this.netServers.map( | ||||||
|       (server) => |       (server) => | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user