fix(portproxy): Improve TLS handshake timeout handling and connection piping in PortProxy
This commit is contained in:
		| @@ -1,5 +1,13 @@ | |||||||
| # Changelog | # Changelog | ||||||
|  |  | ||||||
|  | ## 2025-03-12 - 3.41.8 - fix(portproxy) | ||||||
|  | Improve TLS handshake timeout handling and connection piping in PortProxy | ||||||
|  |  | ||||||
|  | - Increase the default initial handshake timeout from 60 seconds to 120 seconds | ||||||
|  | - Add a 30-second grace period before terminating connections waiting for initial TLS data | ||||||
|  | - Refactor piping logic by removing redundant callback and establishing piping immediately after flushing buffered data | ||||||
|  | - Enhance debug logging during TLS ClientHello processing for improved SNI extraction insights | ||||||
|  |  | ||||||
| ## 2025-03-12 - 3.41.7 - fix(core) | ## 2025-03-12 - 3.41.7 - fix(core) | ||||||
| Refactor PortProxy and SniHandler: improve configuration handling, logging, and whitespace consistency | Refactor PortProxy and SniHandler: improve configuration handling, logging, and whitespace consistency | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,6 +3,6 @@ | |||||||
|  */ |  */ | ||||||
| export const commitinfo = { | export const commitinfo = { | ||||||
|   name: '@push.rocks/smartproxy', |   name: '@push.rocks/smartproxy', | ||||||
|   version: '3.41.7', |   version: '3.41.8', | ||||||
|   description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.' |   description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.' | ||||||
| } | } | ||||||
|   | |||||||
| @@ -216,7 +216,7 @@ export class PortProxy { | |||||||
|       targetIP: settingsArg.targetIP || 'localhost', |       targetIP: settingsArg.targetIP || 'localhost', | ||||||
|  |  | ||||||
|       // Timeout settings with reasonable defaults |       // Timeout settings with reasonable defaults | ||||||
|       initialDataTimeout: settingsArg.initialDataTimeout || 60000, // 60 seconds for initial handshake |       initialDataTimeout: settingsArg.initialDataTimeout || 120000, // 120 seconds for initial handshake | ||||||
|       socketTimeout: ensureSafeTimeout(settingsArg.socketTimeout || 3600000), // 1 hour socket timeout |       socketTimeout: ensureSafeTimeout(settingsArg.socketTimeout || 3600000), // 1 hour socket timeout | ||||||
|       inactivityCheckInterval: settingsArg.inactivityCheckInterval || 60000, // 60 seconds interval |       inactivityCheckInterval: settingsArg.inactivityCheckInterval || 60000, // 60 seconds interval | ||||||
|       maxConnectionLifetime: ensureSafeTimeout(settingsArg.maxConnectionLifetime || 86400000), // 24 hours default |       maxConnectionLifetime: ensureSafeTimeout(settingsArg.maxConnectionLifetime || 86400000), // 24 hours default | ||||||
| @@ -853,78 +853,76 @@ export class PortProxy { | |||||||
|  |  | ||||||
|       // Process any remaining data in the queue before switching to piping |       // Process any remaining data in the queue before switching to piping | ||||||
|       processDataQueue(); |       processDataQueue(); | ||||||
|  |        | ||||||
|       // Setup function to establish piping - we'll use this after flushing data |       // Set up piping immediately - don't delay this crucial step | ||||||
|       const setupPiping = () => { |       pipingEstablished = true; | ||||||
|         // Mark that we're switching to piping mode |        | ||||||
|         pipingEstablished = true; |  | ||||||
|  |  | ||||||
|         // Setup piping in both directions |  | ||||||
|         socket.pipe(targetSocket); |  | ||||||
|         targetSocket.pipe(socket); |  | ||||||
|  |  | ||||||
|         // Resume the socket to ensure data flows |  | ||||||
|         socket.resume(); |  | ||||||
|  |  | ||||||
|         // Process any data that might be queued in the interim |  | ||||||
|         if (dataQueue.length > 0) { |  | ||||||
|           // Write any remaining queued data directly to the target socket |  | ||||||
|           for (const chunk of dataQueue) { |  | ||||||
|             targetSocket.write(chunk); |  | ||||||
|           } |  | ||||||
|           // Clear the queue |  | ||||||
|           dataQueue.length = 0; |  | ||||||
|           queueSize = 0; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (this.settings.enableDetailedLogging) { |  | ||||||
|           console.log( |  | ||||||
|             `[${connectionId}] Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` + |  | ||||||
|               `${ |  | ||||||
|                 serverName |  | ||||||
|                   ? ` (SNI: ${serverName})` |  | ||||||
|                   : domainConfig |  | ||||||
|                   ? ` (Port-based for domain: ${domainConfig.domains.join(', ')})` |  | ||||||
|                   : '' |  | ||||||
|               }` + |  | ||||||
|               ` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${ |  | ||||||
|                 record.hasKeepAlive ? 'Yes' : 'No' |  | ||||||
|               }` |  | ||||||
|           ); |  | ||||||
|         } else { |  | ||||||
|           console.log( |  | ||||||
|             `Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` + |  | ||||||
|               `${ |  | ||||||
|                 serverName |  | ||||||
|                   ? ` (SNI: ${serverName})` |  | ||||||
|                   : domainConfig |  | ||||||
|                   ? ` (Port-based for domain: ${domainConfig.domains.join(', ')})` |  | ||||||
|                   : '' |  | ||||||
|               }` |  | ||||||
|           ); |  | ||||||
|         } |  | ||||||
|       }; |  | ||||||
|  |  | ||||||
|       // Flush all pending data to target |       // Flush all pending data to target | ||||||
|       if (record.pendingData.length > 0) { |       if (record.pendingData.length > 0) { | ||||||
|         const combinedData = Buffer.concat(record.pendingData); |         const combinedData = Buffer.concat(record.pendingData); | ||||||
|  |          | ||||||
|  |         if (this.settings.enableDetailedLogging) { | ||||||
|  |           console.log(`[${connectionId}] Forwarding ${combinedData.length} bytes of initial data to target`); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // Write pending data immediately | ||||||
|         targetSocket.write(combinedData, (err) => { |         targetSocket.write(combinedData, (err) => { | ||||||
|           if (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'); |             return this.initiateCleanupOnce(record, 'write_error'); | ||||||
|           } |           } | ||||||
|  |  | ||||||
|           // Establish piping now that we've flushed the buffered data |  | ||||||
|           setupPiping(); |  | ||||||
|         }); |         }); | ||||||
|  |          | ||||||
|  |         // Clear the buffer now that we've processed it | ||||||
|  |         record.pendingData = []; | ||||||
|  |         record.pendingDataSize = 0; | ||||||
|  |       } | ||||||
|  |        | ||||||
|  |       // Setup piping in both directions without any delays | ||||||
|  |       socket.pipe(targetSocket); | ||||||
|  |       targetSocket.pipe(socket); | ||||||
|  |        | ||||||
|  |       // Resume the socket to ensure data flows - CRITICAL! | ||||||
|  |       socket.resume(); | ||||||
|  |        | ||||||
|  |       // Process any data that might be queued in the interim | ||||||
|  |       if (dataQueue.length > 0) { | ||||||
|  |         // Write any remaining queued data directly to the target socket | ||||||
|  |         for (const chunk of dataQueue) { | ||||||
|  |           targetSocket.write(chunk); | ||||||
|  |         } | ||||||
|  |         // Clear the queue | ||||||
|  |         dataQueue.length = 0; | ||||||
|  |         queueSize = 0; | ||||||
|  |       } | ||||||
|  |        | ||||||
|  |       if (this.settings.enableDetailedLogging) { | ||||||
|  |         console.log( | ||||||
|  |           `[${connectionId}] Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` + | ||||||
|  |             `${ | ||||||
|  |               serverName | ||||||
|  |                 ? ` (SNI: ${serverName})` | ||||||
|  |                 : domainConfig | ||||||
|  |                 ? ` (Port-based for domain: ${domainConfig.domains.join(', ')})` | ||||||
|  |                 : '' | ||||||
|  |             }` + | ||||||
|  |             ` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${ | ||||||
|  |               record.hasKeepAlive ? 'Yes' : 'No' | ||||||
|  |             }` | ||||||
|  |         ); | ||||||
|       } else { |       } else { | ||||||
|         // No pending data, just establish piping immediately |         console.log( | ||||||
|         setupPiping(); |           `Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` + | ||||||
|  |             `${ | ||||||
|  |               serverName | ||||||
|  |                 ? ` (SNI: ${serverName})` | ||||||
|  |                 : domainConfig | ||||||
|  |                 ? ` (Port-based for domain: ${domainConfig.domains.join(', ')})` | ||||||
|  |                 : '' | ||||||
|  |             }` | ||||||
|  |         ); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       // Clear the buffer now that we've processed it |  | ||||||
|       record.pendingData = []; |  | ||||||
|       record.pendingDataSize = 0; |  | ||||||
|  |  | ||||||
|       // Add the renegotiation handler for SNI validation with strict domain enforcement |       // Add the renegotiation handler for SNI validation with strict domain enforcement | ||||||
|       // This will be called after we've established piping |       // This will be called after we've established piping | ||||||
| @@ -1575,15 +1573,20 @@ export class PortProxy { | |||||||
|         // Set an initial timeout for handshake data |         // Set an initial timeout for handshake data | ||||||
|         let initialTimeout: NodeJS.Timeout | null = setTimeout(() => { |         let initialTimeout: NodeJS.Timeout | null = setTimeout(() => { | ||||||
|           if (!initialDataReceived) { |           if (!initialDataReceived) { | ||||||
|             console.log( |             console.log(`[${connectionId}] Initial data warning (${this.settings.initialDataTimeout}ms) for connection from ${remoteIP}`); | ||||||
|               `[${connectionId}] Initial data timeout (${this.settings.initialDataTimeout}ms) for connection from ${remoteIP} on port ${localPort}` |              | ||||||
|             ); |             // Add a grace period instead of immediate termination | ||||||
|             if (connectionRecord.incomingTerminationReason === null) { |             setTimeout(() => { | ||||||
|               connectionRecord.incomingTerminationReason = 'initial_timeout'; |               if (!initialDataReceived) { | ||||||
|               this.incrementTerminationStat('incoming', 'initial_timeout'); |                 console.log(`[${connectionId}] Final initial data timeout after grace period`); | ||||||
|             } |                 if (connectionRecord.incomingTerminationReason === null) { | ||||||
|             socket.end(); |                   connectionRecord.incomingTerminationReason = 'initial_timeout'; | ||||||
|             this.cleanupConnection(connectionRecord, 'initial_timeout'); |                   this.incrementTerminationStat('incoming', 'initial_timeout'); | ||||||
|  |                 } | ||||||
|  |                 socket.end(); | ||||||
|  |                 this.cleanupConnection(connectionRecord, 'initial_timeout'); | ||||||
|  |               } | ||||||
|  |             }, 30000); // 30 second grace period | ||||||
|           } |           } | ||||||
|         }, this.settings.initialDataTimeout!); |         }, this.settings.initialDataTimeout!); | ||||||
|  |  | ||||||
| @@ -1764,15 +1767,20 @@ export class PortProxy { | |||||||
|         if (this.settings.sniEnabled) { |         if (this.settings.sniEnabled) { | ||||||
|           initialTimeout = setTimeout(() => { |           initialTimeout = setTimeout(() => { | ||||||
|             if (!initialDataReceived) { |             if (!initialDataReceived) { | ||||||
|               console.log( |               console.log(`[${connectionId}] Initial data warning (${this.settings.initialDataTimeout}ms) for connection from ${remoteIP}`); | ||||||
|                 `[${connectionId}] Initial data timeout (${this.settings.initialDataTimeout}ms) for connection from ${remoteIP} on port ${localPort}` |                | ||||||
|               ); |               // Add a grace period instead of immediate termination | ||||||
|               if (connectionRecord.incomingTerminationReason === null) { |               setTimeout(() => { | ||||||
|                 connectionRecord.incomingTerminationReason = 'initial_timeout'; |                 if (!initialDataReceived) { | ||||||
|                 this.incrementTerminationStat('incoming', 'initial_timeout'); |                   console.log(`[${connectionId}] Final initial data timeout after grace period`); | ||||||
|               } |                   if (connectionRecord.incomingTerminationReason === null) { | ||||||
|               socket.end(); |                     connectionRecord.incomingTerminationReason = 'initial_timeout'; | ||||||
|               this.cleanupConnection(connectionRecord, 'initial_timeout'); |                     this.incrementTerminationStat('incoming', 'initial_timeout'); | ||||||
|  |                   } | ||||||
|  |                   socket.end(); | ||||||
|  |                   this.cleanupConnection(connectionRecord, 'initial_timeout'); | ||||||
|  |                 } | ||||||
|  |               }, 30000); // 30 second grace period | ||||||
|             } |             } | ||||||
|           }, this.settings.initialDataTimeout!); |           }, this.settings.initialDataTimeout!); | ||||||
|  |  | ||||||
| @@ -2024,30 +2032,29 @@ export class PortProxy { | |||||||
|           initialDataReceived = false; |           initialDataReceived = false; | ||||||
|  |  | ||||||
|           socket.once('data', (chunk: Buffer) => { |           socket.once('data', (chunk: Buffer) => { | ||||||
|  |             // Clear timeout immediately | ||||||
|             if (initialTimeout) { |             if (initialTimeout) { | ||||||
|               clearTimeout(initialTimeout); |               clearTimeout(initialTimeout); | ||||||
|               initialTimeout = null; |               initialTimeout = null; | ||||||
|             } |             } | ||||||
|  |              | ||||||
|             initialDataReceived = true; |             initialDataReceived = true; | ||||||
|  |              | ||||||
|             // ADD THE DEBUGGING CODE RIGHT HERE, BEFORE ANY OTHER PROCESSING |             // Add debugging ONLY if detailed logging is enabled - avoid heavy processing | ||||||
|             if (SniHandler.isClientHello(chunk)) { |             if (this.settings.enableTlsDebugLogging && SniHandler.isClientHello(chunk)) { | ||||||
|               // Log more details to understand session resumption |               // Move heavy debug logging to a separate async task to not block the flow | ||||||
|               const resumptionInfo = SniHandler.hasSessionResumption(chunk, true); |               setImmediate(() => { | ||||||
|               console.log( |                 try { | ||||||
|                 `[${connectionId}] ClientHello details: isResumption=${resumptionInfo.isResumption}, hasSNI=${resumptionInfo.hasSNI}` |                   const resumptionInfo = SniHandler.hasSessionResumption(chunk, true); | ||||||
|               ); |                   const standardSNI = SniHandler.extractSNI(chunk, true); | ||||||
|  |                   const pskSNI = SniHandler.extractSNIFromPSKExtension(chunk, true); | ||||||
|               // Try both extraction methods |                    | ||||||
|               const standardSNI = SniHandler.extractSNI(chunk, true); |                   console.log(`[${connectionId}] ClientHello details: isResumption=${resumptionInfo.isResumption}, hasSNI=${resumptionInfo.hasSNI}`); | ||||||
|               const pskSNI = SniHandler.extractSNIFromPSKExtension(chunk, true); |                   console.log(`[${connectionId}] SNI extraction results: standardSNI=${standardSNI || 'none'}, pskSNI=${pskSNI || 'none'}`); | ||||||
|  |                 } catch (err) { | ||||||
|               console.log( |                   console.log(`[${connectionId}] Error in debug logging: ${err}`); | ||||||
|                 `[${connectionId}] SNI extraction results: standardSNI=${ |                 } | ||||||
|                   standardSNI || 'none' |               }); | ||||||
|                 }, pskSNI=${pskSNI || 'none'}` |  | ||||||
|               ); |  | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Block non-TLS connections on port 443 |             // Block non-TLS connections on port 443 | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user