From b9210d891eaaca3dc3a695ec0046c3907b78c28b Mon Sep 17 00:00:00 2001 From: Philipp Kunz Date: Sun, 23 Feb 2025 17:30:41 +0000 Subject: [PATCH] fix(PortProxy): Refactor and optimize PortProxy for improved readability and maintainability --- changelog.md | 8 + ts/00_commitinfo_data.ts | 2 +- ts/smartproxy.portproxy.ts | 314 ++++++++++++++----------------------- 3 files changed, 125 insertions(+), 199 deletions(-) diff --git a/changelog.md b/changelog.md index af4ca21..4cfe041 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2025-02-23 - 3.10.3 - fix(PortProxy) +Refactor and optimize PortProxy for improved readability and maintainability + +- Simplified and clarified inline comments. +- Optimized the extractSNI function for better readability. +- Streamlined the cleanup process for connections in PortProxy. +- Improved handling and logging of incoming and outgoing connections. + ## 2025-02-23 - 3.10.2 - fix(PortProxy) Fix connection handling to include timeouts for SNI-enabled connections. diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index cce672b..ad71f92 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.10.2', + version: '3.10.3', description: 'a proxy for handling high workloads of proxying' } diff --git a/ts/smartproxy.portproxy.ts b/ts/smartproxy.portproxy.ts index 1d29120..2978370 100644 --- a/ts/smartproxy.portproxy.ts +++ b/ts/smartproxy.portproxy.ts @@ -1,106 +1,73 @@ import * as plugins from './plugins.js'; export interface IDomainConfig { - domain: string; // glob pattern for domain - allowedIPs: string[]; // glob patterns for IPs allowed to access this domain + domain: string; // Glob pattern for domain + allowedIPs: string[]; // Glob patterns for allowed IPs targetIP?: string; // Optional target IP for this domain } export interface IProxySettings extends plugins.tls.TlsOptions { - // Port configuration fromPort: number; toPort: number; - toHost?: string; // Target host to proxy to, defaults to 'localhost' - - // Domain and security settings + toHost?: string; // Target host to proxy to, defaults to 'localhost' domains: IDomainConfig[]; sniEnabled?: boolean; - defaultAllowedIPs?: string[]; // Optional default IP patterns if no matching domain found - preserveSourceIP?: boolean; // Whether to preserve the client's source IP when proxying + defaultAllowedIPs?: string[]; + preserveSourceIP?: boolean; } /** - * Extract SNI (Server Name Indication) from a TLS ClientHello packet. - * Returns the server name if found, or undefined. + * Extracts the SNI (Server Name Indication) from a TLS ClientHello packet. + * @param buffer - Buffer containing the TLS ClientHello. + * @returns The server name if found, otherwise undefined. */ function extractSNI(buffer: Buffer): string | undefined { let offset = 0; - // We need at least 5 bytes for the record header. - if (buffer.length < 5) { - return undefined; - } + if (buffer.length < 5) return undefined; - // TLS record header const recordType = buffer.readUInt8(0); - if (recordType !== 22) { // 22 = handshake - return undefined; - } - // Read record length + if (recordType !== 22) return undefined; // 22 = handshake + const recordLength = buffer.readUInt16BE(3); - if (buffer.length < 5 + recordLength) { - // Not all data arrived yet; in production you might need to accumulate more data. - return undefined; - } + if (buffer.length < 5 + recordLength) return undefined; offset = 5; - // Handshake message type should be 1 for ClientHello. const handshakeType = buffer.readUInt8(offset); - if (handshakeType !== 1) { - return undefined; - } - // Skip handshake header (1 byte type + 3 bytes length) - offset += 4; + if (handshakeType !== 1) return undefined; // 1 = ClientHello - // Skip client version (2 bytes) and random (32 bytes) - offset += 2 + 32; + offset += 4; // Skip handshake header (type + length) + offset += 2 + 32; // Skip client version and random - // Session ID const sessionIDLength = buffer.readUInt8(offset); - offset += 1 + sessionIDLength; + offset += 1 + sessionIDLength; // Skip session ID - // Cipher suites const cipherSuitesLength = buffer.readUInt16BE(offset); - offset += 2 + cipherSuitesLength; + offset += 2 + cipherSuitesLength; // Skip cipher suites - // Compression methods const compressionMethodsLength = buffer.readUInt8(offset); - offset += 1 + compressionMethodsLength; + offset += 1 + compressionMethodsLength; // Skip compression methods - // Extensions length - if (offset + 2 > buffer.length) { - return undefined; - } + if (offset + 2 > buffer.length) return undefined; const extensionsLength = buffer.readUInt16BE(offset); offset += 2; const extensionsEnd = offset + extensionsLength; - // Iterate over extensions while (offset + 4 <= extensionsEnd) { const extensionType = buffer.readUInt16BE(offset); const extensionLength = buffer.readUInt16BE(offset + 2); offset += 4; - - // Check for SNI extension (type 0) - if (extensionType === 0x0000) { - // SNI extension: first 2 bytes are the SNI list length. - if (offset + 2 > buffer.length) { - return undefined; - } + if (extensionType === 0x0000) { // SNI extension + if (offset + 2 > buffer.length) return undefined; const sniListLength = buffer.readUInt16BE(offset); offset += 2; const sniListEnd = offset + sniListLength; - // Loop through the list; typically there is one entry. while (offset + 3 < sniListEnd) { - const nameType = buffer.readUInt8(offset); - offset++; + const nameType = buffer.readUInt8(offset++); const nameLen = buffer.readUInt16BE(offset); offset += 2; if (nameType === 0) { // host_name - if (offset + nameLen > buffer.length) { - return undefined; - } - const serverName = buffer.toString('utf8', offset, offset + nameLen); - return serverName; + if (offset + nameLen > buffer.length) return undefined; + return buffer.toString('utf8', offset, offset + nameLen); } offset += nameLen; } @@ -115,15 +82,11 @@ function extractSNI(buffer: Buffer): string | undefined { export class PortProxy { netServer: plugins.net.Server; settings: IProxySettings; - // Track active incoming connections private activeConnections: Set = new Set(); - // Record start times for incoming connections private incomingConnectionTimes: Map = new Map(); - // Record start times for outgoing connections private outgoingConnectionTimes: Map = new Map(); private connectionLogger: NodeJS.Timeout | null = null; - // Overall termination statistics private terminationStats: { incoming: Record; outgoing: Record; @@ -135,90 +98,66 @@ export class PortProxy { constructor(settings: IProxySettings) { this.settings = { ...settings, - toHost: settings.toHost || 'localhost' + toHost: settings.toHost || 'localhost', }; } - // 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]++; - } + this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1; } public async start() { - // Adjusted cleanUpSockets: forcefully destroy both sockets if they haven't been destroyed. - const cleanUpSockets = (from: plugins.net.Socket, to?: plugins.net.Socket) => { - if (!from.destroyed) { - from.destroy(); - } - if (to && !to.destroyed) { - to.destroy(); - } + // Helper to forcefully destroy sockets. + const cleanUpSockets = (socketA: plugins.net.Socket, socketB?: plugins.net.Socket) => { + if (!socketA.destroyed) socketA.destroy(); + if (socketB && !socketB.destroyed) socketB.destroy(); }; + // Normalize an IP to include both IPv4 and IPv6 representations. const normalizeIP = (ip: string): string[] => { - // Handle IPv4-mapped IPv6 addresses if (ip.startsWith('::ffff:')) { - const ipv4 = ip.slice(7); // Remove '::ffff:' prefix + const ipv4 = ip.slice(7); return [ip, ipv4]; } - // Handle IPv4 addresses by adding IPv4-mapped IPv6 variant if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) { return [ip, `::ffff:${ip}`]; } return [ip]; }; - const isAllowed = (value: string, patterns: string[]): boolean => { - // Expand patterns to include both IPv4 and IPv6 variants + // Check if a given IP matches any of the glob patterns. + const isAllowed = (ip: string, patterns: string[]): boolean => { + const normalizedIPVariants = normalizeIP(ip); const expandedPatterns = patterns.flatMap(normalizeIP); - // Check if any variant of the IP matches any expanded pattern - return normalizeIP(value).some(ip => - expandedPatterns.some(pattern => plugins.minimatch(ip, pattern)) + return normalizedIPVariants.some(ipVariant => + expandedPatterns.some(pattern => plugins.minimatch(ipVariant, pattern)) ); }; - const findMatchingDomain = (serverName: string): IDomainConfig | undefined => { - return this.settings.domains.find(config => plugins.minimatch(serverName, config.domain)); - }; + // Find a matching domain config based on the SNI. + const findMatchingDomain = (serverName: string): IDomainConfig | undefined => + this.settings.domains.find(config => plugins.minimatch(serverName, config.domain)); - // Create a plain net server for TLS passthrough. this.netServer = plugins.net.createServer((socket: plugins.net.Socket) => { const remoteIP = socket.remoteAddress || ''; - - // Record start time for the incoming connection. this.activeConnections.add(socket); this.incomingConnectionTimes.set(socket, Date.now()); console.log(`New connection from ${remoteIP}. Active connections: ${this.activeConnections.size}`); - // 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) { - console.log(`(Premature) Incoming socket error from ${remoteIP} before data received: ${err.message}`); - } else { - console.log(`(Immediate) Incoming socket error from ${remoteIP}: ${err.message}`); - } - }); + let incomingTerminationReason: string | null = null; + let outgoingTerminationReason: string | null = null; + let targetSocket: plugins.net.Socket | null = null; + let connectionClosed = false; // Ensure cleanup happens only once. - let connectionClosed = false; const cleanupOnce = () => { if (!connectionClosed) { connectionClosed = true; - cleanUpSockets(socket, to || undefined); + cleanUpSockets(socket, targetSocket || undefined); this.incomingConnectionTimes.delete(socket); - if (to) { - this.outgoingConnectionTimes.delete(to); + if (targetSocket) { + this.outgoingConnectionTimes.delete(targetSocket); } if (this.activeConnections.has(socket)) { this.activeConnections.delete(socket); @@ -227,10 +166,24 @@ export class PortProxy { } }; - // Outgoing connection placeholder. - let to: plugins.net.Socket | null = null; + // Helper to reject an incoming connection. + const rejectIncomingConnection = (reason: string, logMessage: string) => { + console.log(logMessage); + socket.end(); + if (incomingTerminationReason === null) { + incomingTerminationReason = reason; + this.incrementTerminationStat('incoming', reason); + } + cleanupOnce(); + }; + + socket.on('error', (err: Error) => { + const errorMessage = initialDataReceived + ? `(Immediate) Incoming socket error from ${remoteIP}: ${err.message}` + : `(Premature) Incoming socket error from ${remoteIP} before data received: ${err.message}`; + console.log(errorMessage); + }); - // 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'; @@ -240,73 +193,47 @@ export class PortProxy { } else { console.log(`Error on ${side} side from ${remoteIP}: ${err.message}`); } - if (side === 'incoming' && incomingTermReason === null) { - incomingTermReason = reason; + if (side === 'incoming' && incomingTerminationReason === null) { + incomingTerminationReason = reason; this.incrementTerminationStat('incoming', reason); - } else if (side === 'outgoing' && outgoingTermReason === null) { - outgoingTermReason = reason; + } else if (side === 'outgoing' && outgoingTerminationReason === null) { + outgoingTerminationReason = 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'; + if (side === 'incoming' && incomingTerminationReason === null) { + incomingTerminationReason = 'normal'; this.incrementTerminationStat('incoming', 'normal'); - } else if (side === 'outgoing' && outgoingTermReason === null) { - outgoingTermReason = 'normal'; + } else if (side === 'outgoing' && outgoingTerminationReason === null) { + outgoingTerminationReason = 'normal'; this.incrementTerminationStat('outgoing', 'normal'); } cleanupOnce(); }; - // Setup connection, optionally accepting the initial data chunk. const setupConnection = (serverName: string, initialChunk?: Buffer) => { - // Check if the IP is allowed by default. - const isDefaultAllowed = this.settings.defaultAllowedIPs && isAllowed(remoteIP, this.settings.defaultAllowedIPs); - if (!isDefaultAllowed && serverName) { + const defaultAllowed = this.settings.defaultAllowedIPs && isAllowed(remoteIP, this.settings.defaultAllowedIPs); + + if (!defaultAllowed && serverName) { const domainConfig = findMatchingDomain(serverName); 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; + return rejectIncomingConnection('rejected', `Connection rejected: No matching domain config for ${serverName} from ${remoteIP}`); } 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; + return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for domain ${serverName}`); } - } 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 { + } else if (!defaultAllowed && !serverName) { + return rejectIncomingConnection('rejected', `Connection rejected: No SNI and IP ${remoteIP} not in default allowed list`); + } else if (defaultAllowed && !serverName) { console.log(`Connection allowed: IP ${remoteIP} is in default allowed list`); } - // Determine target host. const domainConfig = serverName ? findMatchingDomain(serverName) : undefined; const targetHost = domainConfig?.targetIP || this.settings.toHost!; - - // Create connection options. const connectionOptions: plugins.net.NetConnectOpts = { host: targetHost, port: this.settings.toPort, @@ -315,49 +242,47 @@ export class PortProxy { connectionOptions.localAddress = remoteIP.replace('::ffff:', ''); } - // Establish outgoing connection. - to = plugins.net.connect(connectionOptions); - if (to) { - this.outgoingConnectionTimes.set(to, Date.now()); + targetSocket = plugins.net.connect(connectionOptions); + if (targetSocket) { + this.outgoingConnectionTimes.set(targetSocket, Date.now()); } - console.log(`Connection established: ${remoteIP} -> ${targetHost}:${this.settings.toPort}${serverName ? ` (SNI: ${serverName})` : ''}`); - - // Push back the initial chunk if provided. + console.log( + `Connection established: ${remoteIP} -> ${targetHost}:${this.settings.toPort}` + + `${serverName ? ` (SNI: ${serverName})` : ''}` + ); + if (initialChunk) { socket.unshift(initialChunk); } socket.setTimeout(120000); - socket.pipe(to!); - to!.pipe(socket); + socket.pipe(targetSocket); + targetSocket.pipe(socket); - // Attach event handlers for both sockets. socket.on('error', handleError('incoming')); - to!.on('error', handleError('outgoing')); + targetSocket.on('error', handleError('outgoing')); socket.on('close', handleClose('incoming')); - to!.on('close', handleClose('outgoing')); + targetSocket.on('close', handleClose('outgoing')); socket.on('timeout', () => { console.log(`Timeout on incoming side from ${remoteIP}`); - if (incomingTermReason === null) { - incomingTermReason = 'timeout'; + if (incomingTerminationReason === null) { + incomingTerminationReason = 'timeout'; this.incrementTerminationStat('incoming', 'timeout'); } cleanupOnce(); }); - to!.on('timeout', () => { + targetSocket.on('timeout', () => { console.log(`Timeout on outgoing side from ${remoteIP}`); - if (outgoingTermReason === null) { - outgoingTermReason = 'timeout'; + if (outgoingTerminationReason === null) { + outgoingTerminationReason = 'timeout'; this.incrementTerminationStat('outgoing', 'timeout'); } cleanupOnce(); }); socket.on('end', handleClose('incoming')); - to!.on('end', handleClose('outgoing')); + targetSocket.on('end', handleClose('outgoing')); }; - // For SNI-enabled connections, set an initial data timeout before waiting for data. if (this.settings.sniEnabled) { - // Set an initial timeout for receiving data (e.g., 5 seconds) socket.setTimeout(5000, () => { console.log(`Initial data timeout for ${remoteIP}`); socket.end(); @@ -365,7 +290,6 @@ export class PortProxy { }); socket.once('data', (chunk: Buffer) => { - // Clear the initial timeout since data has been received socket.setTimeout(0); initialDataReceived = true; const serverName = extractSNI(chunk) || ''; @@ -373,27 +297,22 @@ export class PortProxy { setupConnection(serverName, chunk); }); } else { - // For non-SNI connections, simply check defaultAllowedIPs. initialDataReceived = true; 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; + return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for non-SNI connection`); } setupConnection(''); } }) - .on('error', (err: Error) => { - console.log(`Server Error: ${err.message}`); - }) - .listen(this.settings.fromPort, () => { - console.log(`PortProxy -> OK: Now listening on port ${this.settings.fromPort}${this.settings.sniEnabled ? ' (SNI passthrough enabled)' : ''}`); - }); + .on('error', (err: Error) => { + console.log(`Server Error: ${err.message}`); + }) + .listen(this.settings.fromPort, () => { + console.log( + `PortProxy -> OK: Now listening on port ${this.settings.fromPort}` + + `${this.settings.sniEnabled ? ' (SNI passthrough enabled)' : ''}` + ); + }); // Log active connection count, longest running connection durations, // and termination statistics every 10 seconds. @@ -401,19 +320,18 @@ export class PortProxy { const now = Date.now(); let maxIncoming = 0; for (const startTime of this.incomingConnectionTimes.values()) { - const duration = now - startTime; - if (duration > maxIncoming) { - maxIncoming = duration; - } + maxIncoming = Math.max(maxIncoming, now - startTime); } let maxOutgoing = 0; for (const startTime of this.outgoingConnectionTimes.values()) { - const duration = now - startTime; - if (duration > maxOutgoing) { - maxOutgoing = duration; - } + maxOutgoing = Math.max(maxOutgoing, now - startTime); } - 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)}`); + 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); }