import * as plugins from './plugins.js'; /** Domain configuration with per‐domain allowed port ranges */ export interface IDomainConfig { domain: string; // Glob pattern for domain allowedIPs: string[]; // Glob patterns for allowed IPs targetIP?: string; // Optional target IP for this domain portRanges?: Array<{ from: number; to: number }>; // Optional domain-specific allowed port ranges } /** Port proxy settings including global allowed port ranges */ export interface IPortProxySettings extends plugins.tls.TlsOptions { fromPort: number; toPort: number; targetIP?: string; // Global target host to proxy to, defaults to 'localhost' domains: IDomainConfig[]; sniEnabled?: boolean; defaultAllowedIPs?: string[]; preserveSourceIP?: boolean; maxConnectionLifetime?: number; // (ms) force cleanup of long-lived connections globalPortRanges: Array<{ from: number; to: number }>; // Global allowed port ranges forwardAllGlobalRanges?: boolean; // When true, forwards all connections on global port ranges to the global targetIP } /** * 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; if (buffer.length < 5) return undefined; const recordType = buffer.readUInt8(0); if (recordType !== 22) return undefined; // 22 = handshake const recordLength = buffer.readUInt16BE(3); if (buffer.length < 5 + recordLength) return undefined; offset = 5; const handshakeType = buffer.readUInt8(offset); if (handshakeType !== 1) return undefined; // 1 = ClientHello offset += 4; // Skip handshake header (type + length) offset += 2 + 32; // Skip client version and random const sessionIDLength = buffer.readUInt8(offset); offset += 1 + sessionIDLength; // Skip session ID const cipherSuitesLength = buffer.readUInt16BE(offset); offset += 2 + cipherSuitesLength; // Skip cipher suites const compressionMethodsLength = buffer.readUInt8(offset); offset += 1 + compressionMethodsLength; // Skip compression methods if (offset + 2 > buffer.length) return undefined; const extensionsLength = buffer.readUInt16BE(offset); offset += 2; const extensionsEnd = offset + extensionsLength; while (offset + 4 <= extensionsEnd) { const extensionType = buffer.readUInt16BE(offset); const extensionLength = buffer.readUInt16BE(offset + 2); offset += 4; if (extensionType === 0x0000) { // SNI extension if (offset + 2 > buffer.length) return undefined; const sniListLength = buffer.readUInt16BE(offset); offset += 2; const sniListEnd = offset + sniListLength; while (offset + 3 < sniListEnd) { const nameType = buffer.readUInt8(offset++); const nameLen = buffer.readUInt16BE(offset); offset += 2; if (nameType === 0) { // host_name if (offset + nameLen > buffer.length) return undefined; return buffer.toString('utf8', offset, offset + nameLen); } offset += nameLen; } break; } else { offset += extensionLength; } } return undefined; } interface IConnectionRecord { incoming: plugins.net.Socket; outgoing: plugins.net.Socket | null; incomingStartTime: number; outgoingStartTime?: number; connectionClosed: boolean; cleanupTimer?: NodeJS.Timeout; // Timer to force cleanup after max lifetime/inactivity } export class PortProxy { private netServers: plugins.net.Server[] = []; settings: IPortProxySettings; // Unified record tracking each connection pair. private connectionRecords: Set = new Set(); private connectionLogger: NodeJS.Timeout | null = null; private terminationStats: { incoming: Record; outgoing: Record; } = { incoming: {}, outgoing: {}, }; constructor(settingsArg: IPortProxySettings) { this.settings = { ...settingsArg, targetIP: settingsArg.targetIP || 'localhost', maxConnectionLifetime: settingsArg.maxConnectionLifetime || 600000, }; } private incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void { this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1; } public async start() { // Define a unified connection handler for all listening ports. const connectionHandler = (socket: plugins.net.Socket) => { const remoteIP = socket.remoteAddress || ''; const localPort = socket.localPort; // The port on which this connection was accepted. const connectionRecord: IConnectionRecord = { incoming: socket, outgoing: null, incomingStartTime: Date.now(), connectionClosed: false, }; this.connectionRecords.add(connectionRecord); console.log(`New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionRecords.size}`); let initialDataReceived = false; let incomingTerminationReason: string | null = null; let outgoingTerminationReason: string | null = null; // Ensure cleanup happens only once for the entire connection record. const cleanupOnce = async () => { if (!connectionRecord.connectionClosed) { connectionRecord.connectionClosed = true; if (connectionRecord.cleanupTimer) { clearTimeout(connectionRecord.cleanupTimer); } if (!socket.destroyed) socket.destroy(); if (connectionRecord.outgoing && !connectionRecord.outgoing.destroyed) connectionRecord.outgoing.destroy(); this.connectionRecords.delete(connectionRecord); console.log(`Connection from ${remoteIP} terminated. Active connections: ${this.connectionRecords.size}`); } }; // 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); }); 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' && incomingTerminationReason === null) { incomingTerminationReason = reason; this.incrementTerminationStat('incoming', reason); } else if (side === 'outgoing' && outgoingTerminationReason === null) { outgoingTerminationReason = reason; this.incrementTerminationStat('outgoing', reason); } cleanupOnce(); }; const handleClose = (side: 'incoming' | 'outgoing') => () => { console.log(`Connection closed on ${side} side from ${remoteIP}`); if (side === 'incoming' && incomingTerminationReason === null) { incomingTerminationReason = 'normal'; this.incrementTerminationStat('incoming', 'normal'); } else if (side === 'outgoing' && outgoingTerminationReason === null) { outgoingTerminationReason = 'normal'; this.incrementTerminationStat('outgoing', 'normal'); } cleanupOnce(); }; /** * Sets up the connection to the target host. * @param serverName - The SNI hostname (unused when forcedDomain is provided). * @param initialChunk - Optional initial data chunk. * @param forcedDomain - If provided, overrides SNI/domain lookup (used for port-based routing). * @param overridePort - If provided, use this port for the outgoing connection (typically the same as the incoming port). */ const setupConnection = (serverName: string, initialChunk?: Buffer, forcedDomain?: IDomainConfig, overridePort?: number) => { // If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup. const domainConfig = forcedDomain ? forcedDomain : (serverName ? this.settings.domains.find(config => plugins.minimatch(serverName, config.domain)) : undefined); // If a matching domain config exists, check its allowedIPs. if (domainConfig) { if (!isAllowed(remoteIP, domainConfig.allowedIPs)) { return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for domain ${domainConfig.domain}`); } } else if (this.settings.defaultAllowedIPs) { // Only check default allowed IPs if no domain config matched. if (!isAllowed(remoteIP, this.settings.defaultAllowedIPs)) { return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed by default allowed list`); } } const targetHost = domainConfig?.targetIP || this.settings.targetIP!; const connectionOptions: plugins.net.NetConnectOpts = { host: targetHost, port: overridePort !== undefined ? overridePort : this.settings.toPort, }; if (this.settings.preserveSourceIP) { connectionOptions.localAddress = remoteIP.replace('::ffff:', ''); } const targetSocket = plugins.net.connect(connectionOptions); connectionRecord.outgoing = targetSocket; connectionRecord.outgoingStartTime = Date.now(); console.log( `Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` + `${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domain})` : ''}` ); if (initialChunk) { socket.unshift(initialChunk); } socket.setTimeout(120000); socket.pipe(targetSocket); targetSocket.pipe(socket); // Attach error and close handlers. socket.on('error', handleError('incoming')); targetSocket.on('error', handleError('outgoing')); socket.on('close', handleClose('incoming')); targetSocket.on('close', handleClose('outgoing')); socket.on('timeout', () => { console.log(`Timeout on incoming side from ${remoteIP}`); if (incomingTerminationReason === null) { incomingTerminationReason = 'timeout'; this.incrementTerminationStat('incoming', 'timeout'); } cleanupOnce(); }); targetSocket.on('timeout', () => { console.log(`Timeout on outgoing side from ${remoteIP}`); if (outgoingTerminationReason === null) { outgoingTerminationReason = 'timeout'; this.incrementTerminationStat('outgoing', 'timeout'); } cleanupOnce(); }); socket.on('end', handleClose('incoming')); targetSocket.on('end', handleClose('outgoing')); // Initialize a cleanup timer for max connection lifetime. if (this.settings.maxConnectionLifetime) { let incomingActive = false; let outgoingActive = false; const resetCleanupTimer = () => { if (this.settings.maxConnectionLifetime) { if (connectionRecord.cleanupTimer) { clearTimeout(connectionRecord.cleanupTimer); } connectionRecord.cleanupTimer = setTimeout(() => { console.log(`Connection from ${remoteIP} exceeded max lifetime with inactivity (${this.settings.maxConnectionLifetime}ms), forcing cleanup.`); cleanupOnce(); }, this.settings.maxConnectionLifetime); } }; resetCleanupTimer(); socket.on('data', () => { incomingActive = true; if (incomingActive && outgoingActive) { resetCleanupTimer(); incomingActive = false; outgoingActive = false; } }); targetSocket.on('data', () => { outgoingActive = true; if (incomingActive && outgoingActive) { resetCleanupTimer(); incomingActive = false; outgoingActive = false; } }); } }; // --- PORT RANGE-BASED HANDLING --- // Only apply port-based rules if the incoming port is within one of the global port ranges. if (this.settings.globalPortRanges && isPortInRanges(localPort, this.settings.globalPortRanges)) { if (this.settings.forwardAllGlobalRanges) { if (this.settings.defaultAllowedIPs && !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) { console.log(`Connection from ${remoteIP} rejected: IP ${remoteIP} not allowed in global default allowed list.`); socket.end(); return; } console.log(`Port-based connection from ${remoteIP} on port ${localPort} forwarded to global target IP ${this.settings.targetIP}.`); setupConnection('', undefined, { domain: 'global', allowedIPs: this.settings.defaultAllowedIPs || [], targetIP: this.settings.targetIP, portRanges: [] }, localPort); return; } else { // Attempt to find a matching forced domain config based on the local port. const forcedDomain = this.settings.domains.find( domain => domain.portRanges && domain.portRanges.length > 0 && isPortInRanges(localPort, domain.portRanges) ); if (forcedDomain) { if (!isAllowed(remoteIP, forcedDomain.allowedIPs)) { console.log(`Connection from ${remoteIP} rejected: IP not allowed for domain ${forcedDomain.domain} on port ${localPort}.`); socket.end(); return; } console.log(`Port-based connection from ${remoteIP} on port ${localPort} matched domain ${forcedDomain.domain}.`); setupConnection('', undefined, forcedDomain, localPort); return; } // Fall through to SNI/default handling if no forced domain config is found. } } // --- FALLBACK: SNI-BASED HANDLING (or default when SNI is disabled) --- if (this.settings.sniEnabled) { socket.setTimeout(5000, () => { console.log(`Initial data timeout for ${remoteIP}`); socket.end(); cleanupOnce(); }); socket.once('data', (chunk: Buffer) => { socket.setTimeout(0); initialDataReceived = true; const serverName = extractSNI(chunk) || ''; console.log(`Received connection from ${remoteIP} with SNI: ${serverName}`); setupConnection(serverName, chunk); }); } else { initialDataReceived = true; if (!this.settings.defaultAllowedIPs || !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) { return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for non-SNI connection`); } setupConnection(''); } }; // --- SETUP LISTENERS --- // Determine which ports to listen on. const listeningPorts = new Set(); if (this.settings.globalPortRanges && this.settings.globalPortRanges.length > 0) { // Listen on every port defined by the global ranges. for (const range of this.settings.globalPortRanges) { for (let port = range.from; port <= range.to; port++) { listeningPorts.add(port); } } // Also ensure the default fromPort is listened to if it isn’t already in the ranges. listeningPorts.add(this.settings.fromPort); } else { listeningPorts.add(this.settings.fromPort); } // Create a server for each port. for (const port of listeningPorts) { const server = plugins.net .createServer(connectionHandler) .on('error', (err: Error) => { console.log(`Server Error on port ${port}: ${err.message}`); }); server.listen(port, () => { console.log(`PortProxy -> OK: Now listening on port ${port}${this.settings.sniEnabled ? ' (SNI passthrough enabled)' : ''}`); }); this.netServers.push(server); } // Log active connection count and longest running durations every 10 seconds. this.connectionLogger = setInterval(() => { const now = Date.now(); let maxIncoming = 0; let maxOutgoing = 0; for (const record of this.connectionRecords) { maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime); if (record.outgoingStartTime) { maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime); } } console.log( `(Interval Log) Active connections: ${this.connectionRecords.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); } public async stop() { // Close all servers. const closePromises: Promise[] = this.netServers.map( server => new Promise((resolve) => { server.close(() => resolve()); }) ); if (this.connectionLogger) { clearInterval(this.connectionLogger); this.connectionLogger = null; } await Promise.all(closePromises); } } // Helper: Check if a port falls within any of the given port ranges. const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }>): boolean => { return ranges.some(range => port >= range.from && port <= range.to); }; // Helper: Check if a given IP matches any of the glob patterns. const isAllowed = (ip: string, patterns: string[]): boolean => { const normalizeIP = (ip: string): string[] => { if (ip.startsWith('::ffff:')) { const ipv4 = ip.slice(7); return [ip, ipv4]; } if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) { return [ip, `::ffff:${ip}`]; } return [ip]; }; const normalizedIPVariants = normalizeIP(ip); const expandedPatterns = patterns.flatMap(normalizeIP); return normalizedIPVariants.some(ipVariant => expandedPatterns.some(pattern => plugins.minimatch(ipVariant, pattern)) ); };