feat(smartproxy.portproxy): Enhance PortProxy with detailed connection statistics and termination tracking
This commit is contained in:
		| @@ -1,5 +1,13 @@ | ||||
| # Changelog | ||||
|  | ||||
| ## 2025-02-22 - 3.10.0 - feat(smartproxy.portproxy) | ||||
| Enhance PortProxy with detailed connection statistics and termination tracking | ||||
|  | ||||
| - Added tracking of termination statistics for incoming and outgoing connections | ||||
| - Enhanced logging to include detailed termination statistics | ||||
| - Introduced helpers to update and log termination stats | ||||
| - Retained detailed connection duration and active connection logging | ||||
|  | ||||
| ## 2025-02-22 - 3.9.4 - fix(PortProxy) | ||||
| Ensure proper cleanup on connection rejection in PortProxy | ||||
|  | ||||
|   | ||||
| @@ -3,6 +3,6 @@ | ||||
|  */ | ||||
| export const commitinfo = { | ||||
|   name: '@push.rocks/smartproxy', | ||||
|   version: '3.9.4', | ||||
|   version: '3.10.0', | ||||
|   description: 'a proxy for handling high workloads of proxying' | ||||
| } | ||||
|   | ||||
| @@ -123,6 +123,15 @@ export class PortProxy { | ||||
|   private outgoingConnectionTimes: Map<plugins.net.Socket, number> = new Map(); | ||||
|   private connectionLogger: NodeJS.Timeout | null = null; | ||||
|  | ||||
|   // Overall termination statistics | ||||
|   private terminationStats: { | ||||
|     incoming: Record<string, number>; | ||||
|     outgoing: Record<string, number>; | ||||
|   } = { | ||||
|     incoming: {}, | ||||
|     outgoing: {}, | ||||
|   }; | ||||
|  | ||||
|   constructor(settings: IProxySettings) { | ||||
|     this.settings = { | ||||
|       ...settings, | ||||
| @@ -130,6 +139,15 @@ export class PortProxy { | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   // Helper to update termination stats. | ||||
|   private incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void { | ||||
|     if (!this.terminationStats[side][reason]) { | ||||
|       this.terminationStats[side][reason] = 1; | ||||
|     } else { | ||||
|       this.terminationStats[side][reason]++; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public async start() { | ||||
|     // Adjusted cleanUpSockets to allow an optional outgoing socket. | ||||
|     const cleanUpSockets = (from: plugins.net.Socket, to?: plugins.net.Socket) => { | ||||
| @@ -183,6 +201,10 @@ export class PortProxy { | ||||
|       // Flag to detect if we've received the first data chunk. | ||||
|       let initialDataReceived = false; | ||||
|  | ||||
|       // Local termination reason trackers for each side. | ||||
|       let incomingTermReason: string | null = null; | ||||
|       let outgoingTermReason: string | null = null; | ||||
|  | ||||
|       // Immediately attach an error handler to catch early errors. | ||||
|       socket.on('error', (err: Error) => { | ||||
|         if (!initialDataReceived) { | ||||
| @@ -192,7 +214,7 @@ export class PortProxy { | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       // Flag to ensure cleanup happens only once. | ||||
|       // Ensure cleanup happens only once. | ||||
|       let connectionClosed = false; | ||||
|       const cleanupOnce = () => { | ||||
|         if (!connectionClosed) { | ||||
| @@ -209,21 +231,39 @@ export class PortProxy { | ||||
|         } | ||||
|       }; | ||||
|  | ||||
|       // Declare the outgoing connection as possibly null. | ||||
|       // Outgoing connection placeholder. | ||||
|       let to: plugins.net.Socket | null = null; | ||||
|  | ||||
|       // Handle errors by recording termination reason and cleaning up. | ||||
|       const handleError = (side: 'incoming' | 'outgoing') => (err: Error) => { | ||||
|         const code = (err as any).code; | ||||
|         let reason = 'error'; | ||||
|         if (code === 'ECONNRESET') { | ||||
|           reason = 'econnreset'; | ||||
|           console.log(`ECONNRESET on ${side} side from ${remoteIP}: ${err.message}`); | ||||
|         } else { | ||||
|           console.log(`Error on ${side} side from ${remoteIP}: ${err.message}`); | ||||
|         } | ||||
|         if (side === 'incoming' && incomingTermReason === null) { | ||||
|           incomingTermReason = reason; | ||||
|           this.incrementTerminationStat('incoming', reason); | ||||
|         } else if (side === 'outgoing' && outgoingTermReason === null) { | ||||
|           outgoingTermReason = reason; | ||||
|           this.incrementTerminationStat('outgoing', reason); | ||||
|         } | ||||
|         cleanupOnce(); | ||||
|       }; | ||||
|  | ||||
|       // Handle close events. If no termination reason was recorded, mark as "normal". | ||||
|       const handleClose = (side: 'incoming' | 'outgoing') => () => { | ||||
|         console.log(`Connection closed on ${side} side from ${remoteIP}`); | ||||
|         if (side === 'incoming' && incomingTermReason === null) { | ||||
|           incomingTermReason = 'normal'; | ||||
|           this.incrementTerminationStat('incoming', 'normal'); | ||||
|         } else if (side === 'outgoing' && outgoingTermReason === null) { | ||||
|           outgoingTermReason = 'normal'; | ||||
|           this.incrementTerminationStat('outgoing', 'normal'); | ||||
|         } | ||||
|         cleanupOnce(); | ||||
|       }; | ||||
|  | ||||
| @@ -236,18 +276,30 @@ export class PortProxy { | ||||
|           if (!domainConfig) { | ||||
|             console.log(`Connection rejected: No matching domain config for ${serverName} from ${remoteIP}`); | ||||
|             socket.end(); | ||||
|             if (incomingTermReason === null) { | ||||
|               incomingTermReason = 'rejected'; | ||||
|               this.incrementTerminationStat('incoming', 'rejected'); | ||||
|             } | ||||
|             cleanupOnce(); | ||||
|             return; | ||||
|           } | ||||
|           if (!isAllowed(remoteIP, domainConfig.allowedIPs)) { | ||||
|             console.log(`Connection rejected: IP ${remoteIP} not allowed for domain ${serverName}`); | ||||
|             socket.end(); | ||||
|             if (incomingTermReason === null) { | ||||
|               incomingTermReason = 'rejected'; | ||||
|               this.incrementTerminationStat('incoming', 'rejected'); | ||||
|             } | ||||
|             cleanupOnce(); | ||||
|             return; | ||||
|           } | ||||
|         } else if (!isDefaultAllowed && !serverName) { | ||||
|           console.log(`Connection rejected: No SNI and IP ${remoteIP} not in default allowed list`); | ||||
|           socket.end(); | ||||
|           if (incomingTermReason === null) { | ||||
|             incomingTermReason = 'rejected'; | ||||
|             this.incrementTerminationStat('incoming', 'rejected'); | ||||
|           } | ||||
|           cleanupOnce(); | ||||
|           return; | ||||
|         } else { | ||||
| @@ -269,7 +321,6 @@ export class PortProxy { | ||||
|  | ||||
|         // Establish outgoing connection. | ||||
|         to = plugins.net.connect(connectionOptions); | ||||
|         // Record start time for the outgoing connection. | ||||
|         if (to) { | ||||
|           this.outgoingConnectionTimes.set(to, Date.now()); | ||||
|         } | ||||
| @@ -280,21 +331,28 @@ export class PortProxy { | ||||
|           socket.unshift(initialChunk); | ||||
|         } | ||||
|         socket.setTimeout(120000); | ||||
|         // Since 'to' is not null here, we can use the non-null assertion. | ||||
|         socket.pipe(to!); | ||||
|         to!.pipe(socket); | ||||
|  | ||||
|         // Attach error and close handlers for both sockets. | ||||
|         // Attach event handlers for both sockets. | ||||
|         socket.on('error', handleError('incoming')); | ||||
|         to!.on('error', handleError('outgoing')); | ||||
|         socket.on('close', handleClose('incoming')); | ||||
|         to!.on('close', handleClose('outgoing')); | ||||
|         socket.on('timeout', () => { | ||||
|           console.log(`Timeout on incoming side from ${remoteIP}`); | ||||
|           if (incomingTermReason === null) { | ||||
|             incomingTermReason = 'timeout'; | ||||
|             this.incrementTerminationStat('incoming', 'timeout'); | ||||
|           } | ||||
|           cleanupOnce(); | ||||
|         }); | ||||
|         to!.on('timeout', () => { | ||||
|           console.log(`Timeout on outgoing side from ${remoteIP}`); | ||||
|           if (outgoingTermReason === null) { | ||||
|             outgoingTermReason = 'timeout'; | ||||
|             this.incrementTerminationStat('outgoing', 'timeout'); | ||||
|           } | ||||
|           cleanupOnce(); | ||||
|         }); | ||||
|         socket.on('end', handleClose('incoming')); | ||||
| @@ -305,7 +363,6 @@ export class PortProxy { | ||||
|       if (this.settings.sniEnabled) { | ||||
|         socket.once('data', (chunk: Buffer) => { | ||||
|           initialDataReceived = true; | ||||
|           // Try to extract the server name from the ClientHello. | ||||
|           const serverName = extractSNI(chunk) || ''; | ||||
|           console.log(`Received connection from ${remoteIP} with SNI: ${serverName}`); | ||||
|           setupConnection(serverName, chunk); | ||||
| @@ -316,6 +373,10 @@ export class PortProxy { | ||||
|         if (!this.settings.defaultAllowedIPs || !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) { | ||||
|           console.log(`Connection rejected: IP ${remoteIP} not allowed for non-SNI connection`); | ||||
|           socket.end(); | ||||
|           if (incomingTermReason === null) { | ||||
|             incomingTermReason = 'rejected'; | ||||
|             this.incrementTerminationStat('incoming', 'rejected'); | ||||
|           } | ||||
|           cleanupOnce(); | ||||
|           return; | ||||
|         } | ||||
| @@ -329,7 +390,8 @@ export class PortProxy { | ||||
|       console.log(`PortProxy -> OK: Now listening on port ${this.settings.fromPort}${this.settings.sniEnabled ? ' (SNI passthrough enabled)' : ''}`); | ||||
|     }); | ||||
|  | ||||
|     // Log active connection count and longest running connections every 10 seconds. | ||||
|     // Log active connection count, longest running connection durations, | ||||
|     // and termination statistics every 10 seconds. | ||||
|     this.connectionLogger = setInterval(() => { | ||||
|       const now = Date.now(); | ||||
|       let maxIncoming = 0; | ||||
| @@ -346,7 +408,7 @@ export class PortProxy { | ||||
|           maxOutgoing = duration; | ||||
|         } | ||||
|       } | ||||
|       console.log(`(Interval Log) Active connections: ${this.activeConnections.size}. Longest running incoming: ${plugins.prettyMs(maxIncoming)}, outgoing: ${plugins.prettyMs(maxOutgoing)}`); | ||||
|       console.log(`(Interval Log) Active connections: ${this.activeConnections.size}. Longest running incoming: ${plugins.prettyMs(maxIncoming)}, outgoing: ${plugins.prettyMs(maxOutgoing)}. Termination stats (incoming): ${JSON.stringify(this.terminationStats.incoming)}, (outgoing): ${JSON.stringify(this.terminationStats.outgoing)}`); | ||||
|     }, 10000); | ||||
|   } | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user