feat(smartproxy.portproxy): Enhance PortProxy with detailed connection statistics and termination tracking

This commit is contained in:
Philipp Kunz 2025-02-22 05:46:30 +00:00
parent 2df2f0ceaf
commit 71b5237cd4
3 changed files with 79 additions and 9 deletions

View File

@ -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

View File

@ -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'
}

View File

@ -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);
}