From 71b5237cd4675e48b52d2636e0c28f940c779a76 Mon Sep 17 00:00:00 2001 From: Philipp Kunz Date: Sat, 22 Feb 2025 05:46:30 +0000 Subject: [PATCH] feat(smartproxy.portproxy): Enhance PortProxy with detailed connection statistics and termination tracking --- changelog.md | 8 ++++ ts/00_commitinfo_data.ts | 2 +- ts/smartproxy.portproxy.ts | 78 ++++++++++++++++++++++++++++++++++---- 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/changelog.md b/changelog.md index 0d82caf..b863245 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 4df76c7..34641a6 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.9.4', + version: '3.10.0', description: 'a proxy for handling high workloads of proxying' } diff --git a/ts/smartproxy.portproxy.ts b/ts/smartproxy.portproxy.ts index 626943a..23828c5 100644 --- a/ts/smartproxy.portproxy.ts +++ b/ts/smartproxy.portproxy.ts @@ -123,6 +123,15 @@ export class PortProxy { private outgoingConnectionTimes: Map = new Map(); private connectionLogger: NodeJS.Timeout | null = null; + // Overall termination statistics + private terminationStats: { + incoming: Record; + outgoing: Record; + } = { + 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); }