From e2ee673197f2d29675151913890ab072656dde47 Mon Sep 17 00:00:00 2001 From: Philipp Kunz Date: Fri, 14 Mar 2025 09:53:25 +0000 Subject: [PATCH] BREAKING CHANGE(core): refactor: reorganize internal module structure to use classes.pp.* modules - Renamed port proxy and SNI handler source files to classes.pp.portproxy.js and classes.pp.snihandler.js respectively - Updated import paths in index.ts and test files (e.g. in test.ts and test.router.ts) to reference the new file names - This refactor improves code organization but breaks direct imports from the old paths --- changelog.md | 12 + test/test.portproxy.ts | 6 +- test/test.ts | 8 +- ts/00_commitinfo_data.ts | 2 +- ts/classes.portproxy.ts | 2503 ----------------- ts/classes.pp.acmemanager.ts | 149 + ts/classes.pp.connectionhandler.ts | 982 +++++++ ts/classes.pp.connectionmanager.ts | 446 +++ ts/classes.pp.domainconfigmanager.ts | 123 + ts/classes.pp.interfaces.ts | 136 + ts/classes.pp.networkproxybridge.ts | 258 ++ ts/classes.pp.portproxy.ts | 344 +++ ts/classes.pp.portrangemanager.ts | 214 ++ ts/classes.pp.securitymanager.ts | 147 + ...snihandler.ts => classes.pp.snihandler.ts} | 171 +- ts/classes.pp.timeoutmanager.ts | 190 ++ ts/classes.pp.tlsmanager.ts | 206 ++ ts/index.ts | 4 +- 18 files changed, 3219 insertions(+), 2682 deletions(-) delete mode 100644 ts/classes.portproxy.ts create mode 100644 ts/classes.pp.acmemanager.ts create mode 100644 ts/classes.pp.connectionhandler.ts create mode 100644 ts/classes.pp.connectionmanager.ts create mode 100644 ts/classes.pp.domainconfigmanager.ts create mode 100644 ts/classes.pp.interfaces.ts create mode 100644 ts/classes.pp.networkproxybridge.ts create mode 100644 ts/classes.pp.portproxy.ts create mode 100644 ts/classes.pp.portrangemanager.ts create mode 100644 ts/classes.pp.securitymanager.ts rename ts/{classes.snihandler.ts => classes.pp.snihandler.ts} (89%) create mode 100644 ts/classes.pp.timeoutmanager.ts create mode 100644 ts/classes.pp.tlsmanager.ts diff --git a/changelog.md b/changelog.md index 953899f..f467b36 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # Changelog +## 2025-03-14 - 4.0.0 - BREAKING CHANGE(core) +refactor: reorganize internal module structure to use 'classes.pp.*' modules + +- Renamed port proxy and SNI handler source files to 'classes.pp.portproxy.js' and 'classes.pp.snihandler.js' respectively +- Updated import paths in index.ts and test files (e.g. in test.ts and test.router.ts) to reference the new file names +- This refactor improves code organization but breaks direct imports from the old paths + +- Renamed 'ts/classes.portproxy.ts' to 'ts/classes.pp.portproxy.ts' +- Renamed 'ts/classes.snihandler.ts' to 'ts/classes.pp.snihandler.ts' +- Updated exports in index.ts to export from 'classes.pp.portproxy.js' and 'classes.pp.snihandler.js' +- Updated test files to import modules from new paths + ## 2025-03-12 - 3.41.8 - fix(portproxy) Improve TLS handshake timeout handling and connection piping in PortProxy diff --git a/test/test.portproxy.ts b/test/test.portproxy.ts index 1496114..6157f3b 100644 --- a/test/test.portproxy.ts +++ b/test/test.portproxy.ts @@ -1,6 +1,6 @@ import { expect, tap } from '@push.rocks/tapbundle'; import * as net from 'net'; -import { PortProxy } from '../ts/classes.portproxy.js'; +import { PortProxy } from '../ts/classes.pp.portproxy.js'; let testServer: net.Server; let portProxy: PortProxy; @@ -299,8 +299,8 @@ tap.test('should use round robin for multiple target IPs in domain config', asyn // Don't track this proxy as it doesn't actually start or listen - const firstTarget = (proxyInstance as any).getTargetIP(domainConfig); - const secondTarget = (proxyInstance as any).getTargetIP(domainConfig); + const firstTarget = proxyInstance.domainConfigManager.getTargetIP(domainConfig); + const secondTarget = proxyInstance.domainConfigManager.getTargetIP(domainConfig); expect(firstTarget).toEqual('hostA'); expect(secondTarget).toEqual('hostB'); }); diff --git a/test/test.ts b/test/test.ts index 0a610fa..ead6db8 100644 --- a/test/test.ts +++ b/test/test.ts @@ -226,8 +226,8 @@ tap.test('should start the proxy server', async () => { // Awaiting the update ensures that the SNI context is added before any requests come in. await testProxy.updateProxyConfigs([ { - destinationIp: '127.0.0.1', - destinationPort: '3000', + destinationIps: ['127.0.0.1'], + destinationPorts: [3000], hostName: 'push.rocks', publicKey: testCertificates.publicKey, privateKey: testCertificates.privateKey, @@ -280,8 +280,8 @@ tap.test('should support WebSocket connections', async () => { // Reconfigure proxy with test certificates if necessary await testProxy.updateProxyConfigs([ { - destinationIp: '127.0.0.1', - destinationPort: '3000', + destinationIps: ['127.0.0.1'], + destinationPorts: [3000], hostName: 'push.rocks', publicKey: testCertificates.publicKey, privateKey: testCertificates.privateKey, diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 08a5ec0..4804112 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.41.8', + version: '4.0.0', description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.' } diff --git a/ts/classes.portproxy.ts b/ts/classes.portproxy.ts deleted file mode 100644 index e9c3968..0000000 --- a/ts/classes.portproxy.ts +++ /dev/null @@ -1,2503 +0,0 @@ -import * as plugins from './plugins.js'; -import { NetworkProxy } from './classes.networkproxy.js'; -import { SniHandler } from './classes.snihandler.js'; - -/** Domain configuration with per-domain allowed port ranges */ -export interface IDomainConfig { - domains: string[]; // Glob patterns for domain(s) - allowedIPs: string[]; // Glob patterns for allowed IPs - blockedIPs?: string[]; // Glob patterns for blocked IPs - targetIPs?: string[]; // If multiple targetIPs are given, use round robin. - portRanges?: Array<{ from: number; to: number }>; // Optional port ranges - // Allow domain-specific timeout override - connectionTimeout?: number; // Connection timeout override (ms) - - // NetworkProxy integration options for this specific domain - useNetworkProxy?: boolean; // Whether to use NetworkProxy for this domain - networkProxyPort?: number; // Override default NetworkProxy port for this domain -} - -/** 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' - domainConfigs: IDomainConfig[]; - sniEnabled?: boolean; - defaultAllowedIPs?: string[]; - defaultBlockedIPs?: string[]; - preserveSourceIP?: boolean; - - // Timeout settings - initialDataTimeout?: number; // Timeout for initial data/SNI (ms), default: 60000 (60s) - socketTimeout?: number; // Socket inactivity timeout (ms), default: 3600000 (1h) - inactivityCheckInterval?: number; // How often to check for inactive connections (ms), default: 60000 (60s) - maxConnectionLifetime?: number; // Default max connection lifetime (ms), default: 86400000 (24h) - inactivityTimeout?: number; // Inactivity timeout (ms), default: 14400000 (4h) - - gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown - 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 - - // Socket optimization settings - noDelay?: boolean; // Disable Nagle's algorithm (default: true) - keepAlive?: boolean; // Enable TCP keepalive (default: true) - keepAliveInitialDelay?: number; // Initial delay before sending keepalive probes (ms) - maxPendingDataSize?: number; // Maximum bytes to buffer during connection setup - - // Enhanced features - disableInactivityCheck?: boolean; // Disable inactivity checking entirely - enableKeepAliveProbes?: boolean; // Enable TCP keep-alive probes - enableDetailedLogging?: boolean; // Enable detailed connection logging - enableTlsDebugLogging?: boolean; // Enable TLS handshake debug logging - enableRandomizedTimeouts?: boolean; // Randomize timeouts slightly to prevent thundering herd - allowSessionTicket?: boolean; // Allow TLS session ticket for reconnection (default: true) - - // Rate limiting and security - maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP - connectionRateLimitPerMinute?: number; // Max new connections per minute from a single IP - - // Enhanced keep-alive settings - keepAliveTreatment?: 'standard' | 'extended' | 'immortal'; // How to treat keep-alive connections - keepAliveInactivityMultiplier?: number; // Multiplier for inactivity timeout for keep-alive connections - extendedKeepAliveLifetime?: number; // Extended lifetime for keep-alive connections (ms) - - // NetworkProxy integration - useNetworkProxy?: number[]; // Array of ports to forward to NetworkProxy - networkProxyPort?: number; // Port where NetworkProxy is listening (default: 8443) - - // ACME certificate management options - acme?: { - enabled?: boolean; // Whether to enable automatic certificate management - port?: number; // Port to listen on for ACME challenges (default: 80) - contactEmail?: string; // Email for Let's Encrypt account - useProduction?: boolean; // Whether to use Let's Encrypt production (default: false for staging) - renewThresholdDays?: number; // Days before expiry to renew certificates (default: 30) - autoRenew?: boolean; // Whether to automatically renew certificates (default: true) - certificateStore?: string; // Directory to store certificates (default: ./certs) - skipConfiguredCerts?: boolean; // Skip domains that already have certificates configured - }; -} - -/** - * Enhanced connection record - */ -interface IConnectionRecord { - id: string; // Unique connection identifier - incoming: plugins.net.Socket; - outgoing: plugins.net.Socket | null; - incomingStartTime: number; - outgoingStartTime?: number; - outgoingClosedTime?: number; - lockedDomain?: string; // Used to lock this connection to the initial SNI - connectionClosed: boolean; // Flag to prevent multiple cleanup attempts - cleanupTimer?: NodeJS.Timeout; // Timer for max lifetime/inactivity - lastActivity: number; // Last activity timestamp for inactivity detection - pendingData: Buffer[]; // Buffer to hold data during connection setup - pendingDataSize: number; // Track total size of pending data - - // Enhanced tracking fields - bytesReceived: number; // Total bytes received - bytesSent: number; // Total bytes sent - remoteIP: string; // Remote IP (cached for logging after socket close) - localPort: number; // Local port (cached for logging) - isTLS: boolean; // Whether this connection is a TLS connection - tlsHandshakeComplete: boolean; // Whether the TLS handshake is complete - hasReceivedInitialData: boolean; // Whether initial data has been received - domainConfig?: IDomainConfig; // Associated domain config for this connection - - // Keep-alive tracking - hasKeepAlive: boolean; // Whether keep-alive is enabled for this connection - inactivityWarningIssued?: boolean; // Whether an inactivity warning has been issued - incomingTerminationReason?: string | null; // Reason for incoming termination - outgoingTerminationReason?: string | null; // Reason for outgoing termination - - // NetworkProxy tracking - usingNetworkProxy?: boolean; // Whether this connection is using a NetworkProxy - - // Renegotiation handler - renegotiationHandler?: (chunk: Buffer) => void; // Handler for renegotiation detection - - // Browser connection tracking - isBrowserConnection?: boolean; // Whether this connection appears to be from a browser - domainSwitches?: number; // Number of times the domain has been switched on this connection -} - -// SNI functions are now imported from SniHandler class -// No need for wrapper functions - -// 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 => { - if (!ip || !patterns || patterns.length === 0) return false; - - const normalizeIP = (ip: string): string[] => { - if (!ip) return []; - 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); - if (normalizedIPVariants.length === 0) return false; - - const expandedPatterns = patterns.flatMap(normalizeIP); - return normalizedIPVariants.some((ipVariant) => - expandedPatterns.some((pattern) => plugins.minimatch(ipVariant, pattern)) - ); -}; - -// Helper: Check if an IP is allowed considering allowed and blocked glob patterns -const isGlobIPAllowed = (ip: string, allowed: string[], blocked: string[] = []): boolean => { - if (!ip) return false; - if (blocked.length > 0 && isAllowed(ip, blocked)) return false; - return isAllowed(ip, allowed); -}; - -// Helper: Generate a unique connection ID -const generateConnectionId = (): string => { - return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); -}; - -// SNI functions are now imported from SniHandler class - -// Helper: Ensure timeout values don't exceed Node.js max safe integer -const ensureSafeTimeout = (timeout: number): number => { - const MAX_SAFE_TIMEOUT = 2147483647; // Maximum safe value (2^31 - 1) - return Math.min(Math.floor(timeout), MAX_SAFE_TIMEOUT); -}; - -// Helper: Generate a slightly randomized timeout to prevent thundering herd -const randomizeTimeout = (baseTimeout: number, variationPercent: number = 5): number => { - const safeBaseTimeout = ensureSafeTimeout(baseTimeout); - const variation = safeBaseTimeout * (variationPercent / 100); - return ensureSafeTimeout(safeBaseTimeout + Math.floor(Math.random() * variation * 2) - variation); -}; - -export class PortProxy { - private netServers: plugins.net.Server[] = []; - settings: IPortProxySettings; - private connectionRecords: Map = new Map(); - private connectionLogger: NodeJS.Timeout | null = null; - private isShuttingDown: boolean = false; - - // Map to track round robin indices for each domain config - private domainTargetIndices: Map = new Map(); - - // Enhanced stats tracking - private terminationStats: { - incoming: Record; - outgoing: Record; - } = { - incoming: {}, - outgoing: {}, - }; - - // Connection tracking by IP for rate limiting - private connectionsByIP: Map> = new Map(); - private connectionRateByIP: Map = new Map(); - - // NetworkProxy instance for TLS termination - private networkProxy: NetworkProxy | null = null; - - constructor(settingsArg: IPortProxySettings) { - // Set reasonable defaults for all settings - this.settings = { - ...settingsArg, - targetIP: settingsArg.targetIP || 'localhost', - - // Timeout settings with reasonable defaults - initialDataTimeout: settingsArg.initialDataTimeout || 120000, // 120 seconds for initial handshake - socketTimeout: ensureSafeTimeout(settingsArg.socketTimeout || 3600000), // 1 hour socket timeout - inactivityCheckInterval: settingsArg.inactivityCheckInterval || 60000, // 60 seconds interval - maxConnectionLifetime: ensureSafeTimeout(settingsArg.maxConnectionLifetime || 86400000), // 24 hours default - inactivityTimeout: ensureSafeTimeout(settingsArg.inactivityTimeout || 14400000), // 4 hours inactivity timeout - - gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000, // 30 seconds - - // Socket optimization settings - noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true, - keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true, - keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 10000, // 10 seconds - maxPendingDataSize: settingsArg.maxPendingDataSize || 10 * 1024 * 1024, // 10MB - - // Feature flags - disableInactivityCheck: settingsArg.disableInactivityCheck || false, - enableKeepAliveProbes: - settingsArg.enableKeepAliveProbes !== undefined ? settingsArg.enableKeepAliveProbes : true, - enableDetailedLogging: settingsArg.enableDetailedLogging || false, - enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false, - enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false, - allowSessionTicket: - settingsArg.allowSessionTicket !== undefined ? settingsArg.allowSessionTicket : true, - - // Rate limiting defaults - maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100, - connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300, - - // Enhanced keep-alive settings - keepAliveTreatment: settingsArg.keepAliveTreatment || 'extended', - keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6, - extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000, // 7 days - - // NetworkProxy settings - networkProxyPort: settingsArg.networkProxyPort || 8443, // Default NetworkProxy port - - // ACME certificate settings with reasonable defaults - acme: settingsArg.acme || { - enabled: false, - port: 80, - contactEmail: 'admin@example.com', - useProduction: false, - renewThresholdDays: 30, - autoRenew: true, - certificateStore: './certs', - skipConfiguredCerts: false, - }, - }; - - // Initialize NetworkProxy if enabled - if (this.settings.useNetworkProxy && this.settings.useNetworkProxy.length > 0) { - this.initializeNetworkProxy(); - } - } - - /** - * Initialize NetworkProxy instance - */ - private async initializeNetworkProxy(): Promise { - if (!this.networkProxy) { - // Configure NetworkProxy options based on PortProxy settings - const networkProxyOptions: any = { - port: this.settings.networkProxyPort!, - portProxyIntegration: true, - logLevel: this.settings.enableDetailedLogging ? 'debug' : 'info', - }; - - // Add ACME settings if configured - if (this.settings.acme) { - networkProxyOptions.acme = { ...this.settings.acme }; - } - - this.networkProxy = new NetworkProxy(networkProxyOptions); - - console.log(`Initialized NetworkProxy on port ${this.settings.networkProxyPort}`); - - // Convert and apply domain configurations to NetworkProxy - await this.syncDomainConfigsToNetworkProxy(); - } - } - - /** - * Updates the domain configurations for the proxy - * @param newDomainConfigs The new domain configurations - */ - public async updateDomainConfigs(newDomainConfigs: IDomainConfig[]): Promise { - console.log(`Updating domain configurations (${newDomainConfigs.length} configs)`); - this.settings.domainConfigs = newDomainConfigs; - - // If NetworkProxy is initialized, resync the configurations - if (this.networkProxy) { - await this.syncDomainConfigsToNetworkProxy(); - } - } - - /** - * Updates the ACME certificate settings - * @param acmeSettings New ACME settings - */ - public async updateAcmeSettings(acmeSettings: IPortProxySettings['acme']): Promise { - console.log('Updating ACME certificate settings'); - - // Update settings - this.settings.acme = { - ...this.settings.acme, - ...acmeSettings, - }; - - // If NetworkProxy is initialized, update its ACME settings - if (this.networkProxy) { - try { - // Recreate NetworkProxy with new settings if ACME enabled state has changed - if (this.settings.acme.enabled !== acmeSettings.enabled) { - console.log(`ACME enabled state changed to: ${acmeSettings.enabled}`); - - // Stop the current NetworkProxy - await this.networkProxy.stop(); - this.networkProxy = null; - - // Reinitialize with new settings - await this.initializeNetworkProxy(); - - // Use start() to make sure ACME gets initialized if newly enabled - await this.networkProxy.start(); - } else { - // Update existing NetworkProxy with new settings - // Note: Some settings may require a restart to take effect - console.log('Updating ACME settings in NetworkProxy'); - - // For certificate renewals, we might want to trigger checks with the new settings - if (acmeSettings.renewThresholdDays) { - console.log(`Setting new renewal threshold to ${acmeSettings.renewThresholdDays} days`); - // This is implementation-dependent but gives an example - if (this.networkProxy.options.acme) { - this.networkProxy.options.acme.renewThresholdDays = acmeSettings.renewThresholdDays; - } - } - } - } catch (err) { - console.log(`Error updating ACME settings: ${err}`); - } - } - } - - /** - * Synchronizes PortProxy domain configurations to NetworkProxy - * This allows domains configured in PortProxy to be used by NetworkProxy - */ - private async syncDomainConfigsToNetworkProxy(): Promise { - if (!this.networkProxy) { - console.log('Cannot sync configurations - NetworkProxy not initialized'); - return; - } - - try { - // Get SSL certificates from assets - // Import fs directly since it's not in plugins - const fs = await import('fs'); - - let certPair; - try { - certPair = { - key: fs.readFileSync('assets/certs/key.pem', 'utf8'), - cert: fs.readFileSync('assets/certs/cert.pem', 'utf8'), - }; - } catch (certError) { - console.log(`Warning: Could not read default certificates: ${certError}`); - console.log( - 'Using empty certificate placeholders - ACME will generate proper certificates if enabled' - ); - - // Use empty placeholders - NetworkProxy will use its internal defaults - // or ACME will generate proper ones if enabled - certPair = { - key: '', - cert: '', - }; - } - - // Convert domain configs to NetworkProxy configs - const proxyConfigs = this.networkProxy.convertPortProxyConfigs( - this.settings.domainConfigs, - certPair - ); - - // Log ACME-eligible domains if ACME is enabled - if (this.settings.acme?.enabled) { - const acmeEligibleDomains = proxyConfigs - .filter((config) => !config.hostName.includes('*')) // Exclude wildcards - .map((config) => config.hostName); - - if (acmeEligibleDomains.length > 0) { - console.log(`Domains eligible for ACME certificates: ${acmeEligibleDomains.join(', ')}`); - } else { - console.log('No domains eligible for ACME certificates found in configuration'); - } - } - - // Update NetworkProxy with the converted configs - this.networkProxy - .updateProxyConfigs(proxyConfigs) - .then(() => { - console.log( - `Successfully synchronized ${proxyConfigs.length} domain configurations to NetworkProxy` - ); - }) - .catch((err) => { - console.log(`Error synchronizing configurations: ${err.message}`); - }); - } catch (err) { - console.log(`Failed to sync configurations: ${err}`); - } - } - - /** - * Requests a certificate for a specific domain - * @param domain The domain to request a certificate for - * @returns Promise that resolves to true if the request was successful, false otherwise - */ - public async requestCertificate(domain: string): Promise { - if (!this.networkProxy) { - console.log('Cannot request certificate - NetworkProxy not initialized'); - return false; - } - - if (!this.settings.acme?.enabled) { - console.log('Cannot request certificate - ACME is not enabled'); - return false; - } - - try { - const result = await this.networkProxy.requestCertificate(domain); - if (result) { - console.log(`Certificate request for ${domain} submitted successfully`); - } else { - console.log(`Certificate request for ${domain} failed`); - } - return result; - } catch (err) { - console.log(`Error requesting certificate: ${err}`); - return false; - } - } - - /** - * Forwards a TLS connection to a NetworkProxy for handling - * @param connectionId - Unique connection identifier - * @param socket - The incoming client socket - * @param record - The connection record - * @param initialData - Initial data chunk (TLS ClientHello) - * @param customProxyPort - Optional custom port for NetworkProxy (for domain-specific settings) - */ - private forwardToNetworkProxy( - connectionId: string, - socket: plugins.net.Socket, - record: IConnectionRecord, - initialData: Buffer, - customProxyPort?: number - ): void { - // Ensure NetworkProxy is initialized - if (!this.networkProxy) { - console.log( - `[${connectionId}] NetworkProxy not initialized. Using fallback direct connection.` - ); - // Fall back to direct connection - return this.setupDirectConnection( - connectionId, - socket, - record, - undefined, - undefined, - initialData - ); - } - - // Use the custom port if provided, otherwise use the default NetworkProxy port - const proxyPort = customProxyPort || this.networkProxy.getListeningPort(); - const proxyHost = 'localhost'; // Assuming NetworkProxy runs locally - - if (this.settings.enableDetailedLogging) { - console.log( - `[${connectionId}] Forwarding TLS connection to NetworkProxy at ${proxyHost}:${proxyPort}` - ); - } - - // Create a connection to the NetworkProxy - const proxySocket = plugins.net.connect({ - host: proxyHost, - port: proxyPort, - }); - - // Store the outgoing socket in the record - record.outgoing = proxySocket; - record.outgoingStartTime = Date.now(); - record.usingNetworkProxy = true; - - // Set up error handlers - proxySocket.on('error', (err) => { - console.log(`[${connectionId}] Error connecting to NetworkProxy: ${err.message}`); - this.cleanupConnection(record, 'network_proxy_connect_error'); - }); - - // Handle connection to NetworkProxy - proxySocket.on('connect', () => { - if (this.settings.enableDetailedLogging) { - console.log(`[${connectionId}] Connected to NetworkProxy at ${proxyHost}:${proxyPort}`); - } - - // First send the initial data that contains the TLS ClientHello - proxySocket.write(initialData); - - // Now set up bidirectional piping between client and NetworkProxy - socket.pipe(proxySocket); - proxySocket.pipe(socket); - - // Setup cleanup handlers - proxySocket.on('close', () => { - if (this.settings.enableDetailedLogging) { - console.log(`[${connectionId}] NetworkProxy connection closed`); - } - this.cleanupConnection(record, 'network_proxy_closed'); - }); - - socket.on('close', () => { - if (this.settings.enableDetailedLogging) { - console.log( - `[${connectionId}] Client connection closed after forwarding to NetworkProxy` - ); - } - this.cleanupConnection(record, 'client_closed'); - }); - - // Update activity on data transfer - socket.on('data', () => this.updateActivity(record)); - proxySocket.on('data', () => this.updateActivity(record)); - - if (this.settings.enableDetailedLogging) { - console.log(`[${connectionId}] TLS connection successfully forwarded to NetworkProxy`); - } - }); - } - - /** - * Sets up a direct connection to the target (original behavior) - * This is used when NetworkProxy isn't configured or as a fallback - */ - private setupDirectConnection( - connectionId: string, - socket: plugins.net.Socket, - record: IConnectionRecord, - domainConfig: IDomainConfig | undefined, - serverName?: string, - initialChunk?: Buffer, - overridePort?: number - ): void { - // Existing connection setup logic - const targetHost = domainConfig ? this.getTargetIP(domainConfig) : this.settings.targetIP!; - const connectionOptions: plugins.net.NetConnectOpts = { - host: targetHost, - port: overridePort !== undefined ? overridePort : this.settings.toPort, - }; - if (this.settings.preserveSourceIP) { - connectionOptions.localAddress = record.remoteIP.replace('::ffff:', ''); - } - - // Create a safe queue for incoming data using a Buffer array - // We'll use this to ensure we don't lose data during handler transitions - const dataQueue: Buffer[] = []; - let queueSize = 0; - let processingQueue = false; - let drainPending = false; - - // Flag to track if we've switched to the final piping mechanism - // Once this is true, we no longer buffer data in dataQueue - let pipingEstablished = false; - - // Pause the incoming socket to prevent buffer overflows - // This ensures we control the flow of data until piping is set up - socket.pause(); - - // Function to safely process the data queue without losing events - const processDataQueue = () => { - if (processingQueue || dataQueue.length === 0 || pipingEstablished) return; - - processingQueue = true; - - try { - // Process all queued chunks with the current active handler - while (dataQueue.length > 0) { - const chunk = dataQueue.shift()!; - queueSize -= chunk.length; - - // Once piping is established, we shouldn't get here, - // but just in case, pass to the outgoing socket directly - if (pipingEstablished && record.outgoing) { - record.outgoing.write(chunk); - continue; - } - - // Track bytes received - record.bytesReceived += chunk.length; - - // Check for TLS handshake - if (!record.isTLS && SniHandler.isTlsHandshake(chunk)) { - record.isTLS = true; - - if (this.settings.enableTlsDebugLogging) { - console.log( - `[${connectionId}] TLS handshake detected in tempDataHandler, ${chunk.length} bytes` - ); - } - } - - // Check if adding this chunk would exceed the buffer limit - const newSize = record.pendingDataSize + chunk.length; - - if (this.settings.maxPendingDataSize && newSize > this.settings.maxPendingDataSize) { - console.log( - `[${connectionId}] Buffer limit exceeded for connection from ${record.remoteIP}: ${newSize} bytes > ${this.settings.maxPendingDataSize} bytes` - ); - socket.end(); // Gracefully close the socket - this.initiateCleanupOnce(record, 'buffer_limit_exceeded'); - return; - } - - // Buffer the chunk and update the size counter - record.pendingData.push(Buffer.from(chunk)); - record.pendingDataSize = newSize; - this.updateActivity(record); - } - } finally { - processingQueue = false; - - // If there's a pending drain and we've processed everything, - // signal we're ready for more data if we haven't established piping yet - if (drainPending && dataQueue.length === 0 && !pipingEstablished) { - drainPending = false; - socket.resume(); - } - } - }; - - // Unified data handler that safely queues incoming data - const safeDataHandler = (chunk: Buffer) => { - // If piping is already established, just let the pipe handle it - if (pipingEstablished) return; - - // Add to our queue for orderly processing - dataQueue.push(Buffer.from(chunk)); // Make a copy to be safe - queueSize += chunk.length; - - // If queue is getting large, pause socket until we catch up - if (this.settings.maxPendingDataSize && queueSize > this.settings.maxPendingDataSize * 0.8) { - socket.pause(); - drainPending = true; - } - - // Process the queue - processDataQueue(); - }; - - // Add our safe data handler - socket.on('data', safeDataHandler); - - // Add initial chunk to pending data if present - if (initialChunk) { - record.bytesReceived += initialChunk.length; - record.pendingData.push(Buffer.from(initialChunk)); - record.pendingDataSize = initialChunk.length; - } - - // Create the target socket but don't set up piping immediately - const targetSocket = plugins.net.connect(connectionOptions); - record.outgoing = targetSocket; - record.outgoingStartTime = Date.now(); - - // Apply socket optimizations - targetSocket.setNoDelay(this.settings.noDelay); - - // Apply keep-alive settings to the outgoing connection as well - if (this.settings.keepAlive) { - targetSocket.setKeepAlive(true, this.settings.keepAliveInitialDelay); - - // Apply enhanced TCP keep-alive options if enabled - if (this.settings.enableKeepAliveProbes) { - try { - if ('setKeepAliveProbes' in targetSocket) { - (targetSocket as any).setKeepAliveProbes(10); - } - if ('setKeepAliveInterval' in targetSocket) { - (targetSocket as any).setKeepAliveInterval(1000); - } - } catch (err) { - // Ignore errors - these are optional enhancements - if (this.settings.enableDetailedLogging) { - console.log( - `[${connectionId}] Enhanced TCP keep-alive not supported for outgoing socket: ${err}` - ); - } - } - } - } - - // Setup specific error handler for connection phase - targetSocket.once('error', (err) => { - // This handler runs only once during the initial connection phase - const code = (err as any).code; - console.log( - `[${connectionId}] Connection setup error to ${targetHost}:${connectionOptions.port}: ${err.message} (${code})` - ); - - // Resume the incoming socket to prevent it from hanging - socket.resume(); - - if (code === 'ECONNREFUSED') { - console.log( - `[${connectionId}] Target ${targetHost}:${connectionOptions.port} refused connection` - ); - } else if (code === 'ETIMEDOUT') { - console.log( - `[${connectionId}] Connection to ${targetHost}:${connectionOptions.port} timed out` - ); - } else if (code === 'ECONNRESET') { - console.log( - `[${connectionId}] Connection to ${targetHost}:${connectionOptions.port} was reset` - ); - } else if (code === 'EHOSTUNREACH') { - console.log(`[${connectionId}] Host ${targetHost} is unreachable`); - } - - // Clear any existing error handler after connection phase - targetSocket.removeAllListeners('error'); - - // Re-add the normal error handler for established connections - targetSocket.on('error', this.handleError('outgoing', record)); - - if (record.outgoingTerminationReason === null) { - record.outgoingTerminationReason = 'connection_failed'; - this.incrementTerminationStat('outgoing', 'connection_failed'); - } - - // Clean up the connection - this.initiateCleanupOnce(record, `connection_failed_${code}`); - }); - - // Setup close handler - targetSocket.on('close', this.handleClose('outgoing', record)); - socket.on('close', this.handleClose('incoming', record)); - - // Handle timeouts with keep-alive awareness - socket.on('timeout', () => { - // For keep-alive connections, just log a warning instead of closing - if (record.hasKeepAlive) { - console.log( - `[${connectionId}] Timeout event on incoming keep-alive connection from ${ - record.remoteIP - } after ${plugins.prettyMs( - this.settings.socketTimeout || 3600000 - )}. Connection preserved.` - ); - // Don't close the connection - just log - return; - } - - // For non-keep-alive connections, proceed with normal cleanup - console.log( - `[${connectionId}] Timeout on incoming side from ${ - record.remoteIP - } after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}` - ); - if (record.incomingTerminationReason === null) { - record.incomingTerminationReason = 'timeout'; - this.incrementTerminationStat('incoming', 'timeout'); - } - this.initiateCleanupOnce(record, 'timeout_incoming'); - }); - - targetSocket.on('timeout', () => { - // For keep-alive connections, just log a warning instead of closing - if (record.hasKeepAlive) { - console.log( - `[${connectionId}] Timeout event on outgoing keep-alive connection from ${ - record.remoteIP - } after ${plugins.prettyMs( - this.settings.socketTimeout || 3600000 - )}. Connection preserved.` - ); - // Don't close the connection - just log - return; - } - - // For non-keep-alive connections, proceed with normal cleanup - console.log( - `[${connectionId}] Timeout on outgoing side from ${ - record.remoteIP - } after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}` - ); - if (record.outgoingTerminationReason === null) { - record.outgoingTerminationReason = 'timeout'; - this.incrementTerminationStat('outgoing', 'timeout'); - } - this.initiateCleanupOnce(record, 'timeout_outgoing'); - }); - - // Set appropriate timeouts, or disable for immortal keep-alive connections - if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') { - // Disable timeouts completely for immortal connections - socket.setTimeout(0); - targetSocket.setTimeout(0); - - if (this.settings.enableDetailedLogging) { - console.log( - `[${connectionId}] Disabled socket timeouts for immortal keep-alive connection` - ); - } - } else { - // Set normal timeouts for other connections - socket.setTimeout(ensureSafeTimeout(this.settings.socketTimeout || 3600000)); - targetSocket.setTimeout(ensureSafeTimeout(this.settings.socketTimeout || 3600000)); - } - - // Track outgoing data for bytes counting - targetSocket.on('data', (chunk: Buffer) => { - record.bytesSent += chunk.length; - this.updateActivity(record); - }); - - // Wait for the outgoing connection to be ready before setting up piping - targetSocket.once('connect', () => { - // Clear the initial connection error handler - targetSocket.removeAllListeners('error'); - - // Add the normal error handler for established connections - targetSocket.on('error', this.handleError('outgoing', record)); - - // Process any remaining data in the queue before switching to piping - processDataQueue(); - - // Set up piping immediately - don't delay this crucial step - pipingEstablished = true; - - // Flush all pending data to target - if (record.pendingData.length > 0) { - const combinedData = Buffer.concat(record.pendingData); - - if (this.settings.enableDetailedLogging) { - console.log(`[${connectionId}] Forwarding ${combinedData.length} bytes of initial data to target`); - } - - // Write pending data immediately - targetSocket.write(combinedData, (err) => { - if (err) { - console.log(`[${connectionId}] Error writing pending data to target: ${err.message}`); - return this.initiateCleanupOnce(record, 'write_error'); - } - }); - - // Clear the buffer now that we've processed it - record.pendingData = []; - record.pendingDataSize = 0; - } - - // Setup piping in both directions without any delays - socket.pipe(targetSocket); - targetSocket.pipe(socket); - - // Resume the socket to ensure data flows - CRITICAL! - socket.resume(); - - // Process any data that might be queued in the interim - if (dataQueue.length > 0) { - // Write any remaining queued data directly to the target socket - for (const chunk of dataQueue) { - targetSocket.write(chunk); - } - // Clear the queue - dataQueue.length = 0; - queueSize = 0; - } - - if (this.settings.enableDetailedLogging) { - console.log( - `[${connectionId}] Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` + - `${ - serverName - ? ` (SNI: ${serverName})` - : domainConfig - ? ` (Port-based for domain: ${domainConfig.domains.join(', ')})` - : '' - }` + - ` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${ - record.hasKeepAlive ? 'Yes' : 'No' - }` - ); - } else { - console.log( - `Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` + - `${ - serverName - ? ` (SNI: ${serverName})` - : domainConfig - ? ` (Port-based for domain: ${domainConfig.domains.join(', ')})` - : '' - }` - ); - } - - - // Add the renegotiation handler for SNI validation with strict domain enforcement - // This will be called after we've established piping - if (serverName) { - // Define a handler for checking renegotiation with improved detection - const renegotiationHandler = (renegChunk: Buffer) => { - // Only process if this looks like a TLS ClientHello - if (SniHandler.isClientHello(renegChunk)) { - try { - // Extract SNI from ClientHello - // Create a connection info object for the existing connection - const connInfo = { - sourceIp: record.remoteIP, - sourcePort: record.incoming.remotePort || 0, - destIp: record.incoming.localAddress || '', - destPort: record.incoming.localPort || 0, - }; - - // Check for session tickets if allowSessionTicket is disabled - if (this.settings.allowSessionTicket === false) { - // Analyze for session resumption attempt (session ticket or PSK) - const resumptionInfo = SniHandler.hasSessionResumption( - renegChunk, - this.settings.enableTlsDebugLogging - ); - - if (resumptionInfo.isResumption) { - // Always log resumption attempt for easier debugging - // Try to extract SNI for logging - const extractedSNI = SniHandler.extractSNI( - renegChunk, - this.settings.enableTlsDebugLogging - ); - console.log( - `[${connectionId}] Session resumption detected in renegotiation. ` + - `Has SNI: ${resumptionInfo.hasSNI ? 'Yes' : 'No'}, ` + - `SNI value: ${extractedSNI || 'None'}, ` + - `allowSessionTicket: ${this.settings.allowSessionTicket}` - ); - - // Block if there's session resumption without SNI - if (!resumptionInfo.hasSNI) { - console.log( - `[${connectionId}] Session resumption detected in renegotiation without SNI and allowSessionTicket=false. ` + - `Terminating connection to force new TLS handshake.` - ); - this.initiateCleanupOnce(record, 'session_ticket_blocked'); - return; - } else { - if (this.settings.enableDetailedLogging) { - console.log( - `[${connectionId}] Session resumption with SNI detected in renegotiation. ` + - `Allowing connection since SNI is present.` - ); - } - } - } - } - - const newSNI = SniHandler.extractSNIWithResumptionSupport( - renegChunk, - connInfo, - this.settings.enableTlsDebugLogging - ); - - // Skip if no SNI was found - if (!newSNI) return; - - // Handle SNI change during renegotiation - always terminate for domain switches - if (newSNI !== record.lockedDomain) { - // Log and terminate the connection for any SNI change - console.log( - `[${connectionId}] Renegotiation with different SNI: ${record.lockedDomain} -> ${newSNI}. ` + - `Terminating connection - SNI domain switching is not allowed.` - ); - this.initiateCleanupOnce(record, 'sni_mismatch'); - } else if (this.settings.enableDetailedLogging) { - console.log( - `[${connectionId}] Renegotiation detected with same SNI: ${newSNI}. Allowing.` - ); - } - } catch (err) { - console.log( - `[${connectionId}] Error processing ClientHello: ${err}. Allowing connection to continue.` - ); - } - } - }; - - // Store the handler in the connection record so we can remove it during cleanup - record.renegotiationHandler = renegotiationHandler; - - // The renegotiation handler is added when piping is established - // Making it part of setupPiping ensures proper sequencing of event handlers - socket.on('data', renegotiationHandler); - - if (this.settings.enableDetailedLogging) { - console.log( - `[${connectionId}] TLS renegotiation handler installed for SNI domain: ${serverName}` - ); - if (this.settings.allowSessionTicket === false) { - console.log( - `[${connectionId}] Session ticket usage is disabled. Connection will be reset on reconnection attempts.` - ); - } - } - } - - // Set connection timeout with simpler logic - if (record.cleanupTimer) { - clearTimeout(record.cleanupTimer); - } - - // For immortal keep-alive connections, skip setting a timeout completely - if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') { - if (this.settings.enableDetailedLogging) { - console.log( - `[${connectionId}] Keep-alive connection with immortal treatment - no max lifetime` - ); - } - // No cleanup timer for immortal connections - } - // For extended keep-alive connections, use extended timeout - else if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') { - const extendedTimeout = this.settings.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000; // 7 days - const safeTimeout = ensureSafeTimeout(extendedTimeout); - - record.cleanupTimer = setTimeout(() => { - console.log( - `[${connectionId}] Keep-alive connection from ${ - record.remoteIP - } exceeded extended lifetime (${plugins.prettyMs(extendedTimeout)}), forcing cleanup.` - ); - this.initiateCleanupOnce(record, 'extended_lifetime'); - }, safeTimeout); - - // Make sure timeout doesn't keep the process alive - if (record.cleanupTimer.unref) { - record.cleanupTimer.unref(); - } - - if (this.settings.enableDetailedLogging) { - console.log( - `[${connectionId}] Keep-alive connection with extended lifetime of ${plugins.prettyMs( - extendedTimeout - )}` - ); - } - } - // For standard connections, use normal timeout - else { - // Use domain-specific timeout if available, otherwise use default - const connectionTimeout = - record.domainConfig?.connectionTimeout || this.settings.maxConnectionLifetime!; - const safeTimeout = ensureSafeTimeout(connectionTimeout); - - record.cleanupTimer = setTimeout(() => { - console.log( - `[${connectionId}] Connection from ${ - record.remoteIP - } exceeded max lifetime (${plugins.prettyMs(connectionTimeout)}), forcing cleanup.` - ); - this.initiateCleanupOnce(record, 'connection_timeout'); - }, safeTimeout); - - // Make sure timeout doesn't keep the process alive - if (record.cleanupTimer.unref) { - record.cleanupTimer.unref(); - } - } - - // Mark TLS handshake as complete for TLS connections - if (record.isTLS) { - record.tlsHandshakeComplete = true; - - if (this.settings.enableTlsDebugLogging) { - console.log( - `[${connectionId}] TLS handshake complete for connection from ${record.remoteIP}` - ); - } - } - }); - } - - /** - * Get connections count by IP - */ - private getConnectionCountByIP(ip: string): number { - return this.connectionsByIP.get(ip)?.size || 0; - } - - /** - * Check and update connection rate for an IP - */ - private checkConnectionRate(ip: string): boolean { - const now = Date.now(); - const minute = 60 * 1000; - - if (!this.connectionRateByIP.has(ip)) { - this.connectionRateByIP.set(ip, [now]); - return true; - } - - // Get timestamps and filter out entries older than 1 minute - const timestamps = this.connectionRateByIP.get(ip)!.filter((time) => now - time < minute); - timestamps.push(now); - this.connectionRateByIP.set(ip, timestamps); - - // Check if rate exceeds limit - return timestamps.length <= this.settings.connectionRateLimitPerMinute!; - } - - /** - * Track connection by IP - */ - private trackConnectionByIP(ip: string, connectionId: string): void { - if (!this.connectionsByIP.has(ip)) { - this.connectionsByIP.set(ip, new Set()); - } - this.connectionsByIP.get(ip)!.add(connectionId); - } - - /** - * Remove connection tracking for an IP - */ - private removeConnectionByIP(ip: string, connectionId: string): void { - if (this.connectionsByIP.has(ip)) { - const connections = this.connectionsByIP.get(ip)!; - connections.delete(connectionId); - if (connections.size === 0) { - this.connectionsByIP.delete(ip); - } - } - } - - /** - * Track connection termination statistic - */ - private incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void { - this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1; - } - - /** - * Cleans up a connection record. - * Destroys both incoming and outgoing sockets, clears timers, and removes the record. - * @param record - The connection record to clean up - * @param reason - Optional reason for cleanup (for logging) - */ - private cleanupConnection(record: IConnectionRecord, reason: string = 'normal'): void { - if (!record.connectionClosed) { - record.connectionClosed = true; - - // Track connection termination - this.removeConnectionByIP(record.remoteIP, record.id); - - if (record.cleanupTimer) { - clearTimeout(record.cleanupTimer); - record.cleanupTimer = undefined; - } - - // Detailed logging data - const duration = Date.now() - record.incomingStartTime; - const bytesReceived = record.bytesReceived; - const bytesSent = record.bytesSent; - - // Remove all data handlers (both standard and renegotiation) to make sure we clean up properly - if (record.incoming) { - try { - // Remove our safe data handler - record.incoming.removeAllListeners('data'); - - // Reset the handler references - record.renegotiationHandler = undefined; - } catch (err) { - console.log(`[${record.id}] Error removing data handlers: ${err}`); - } - } - - try { - if (!record.incoming.destroyed) { - // Try graceful shutdown first, then force destroy after a short timeout - record.incoming.end(); - const incomingTimeout = setTimeout(() => { - try { - if (record && !record.incoming.destroyed) { - record.incoming.destroy(); - } - } catch (err) { - console.log(`[${record.id}] Error destroying incoming socket: ${err}`); - } - }, 1000); - - // Ensure the timeout doesn't block Node from exiting - if (incomingTimeout.unref) { - incomingTimeout.unref(); - } - } - } catch (err) { - console.log(`[${record.id}] Error closing incoming socket: ${err}`); - try { - if (!record.incoming.destroyed) { - record.incoming.destroy(); - } - } catch (destroyErr) { - console.log(`[${record.id}] Error destroying incoming socket: ${destroyErr}`); - } - } - - try { - if (record.outgoing && !record.outgoing.destroyed) { - // Try graceful shutdown first, then force destroy after a short timeout - record.outgoing.end(); - const outgoingTimeout = setTimeout(() => { - try { - if (record && record.outgoing && !record.outgoing.destroyed) { - record.outgoing.destroy(); - } - } catch (err) { - console.log(`[${record.id}] Error destroying outgoing socket: ${err}`); - } - }, 1000); - - // Ensure the timeout doesn't block Node from exiting - if (outgoingTimeout.unref) { - outgoingTimeout.unref(); - } - } - } catch (err) { - console.log(`[${record.id}] Error closing outgoing socket: ${err}`); - try { - if (record.outgoing && !record.outgoing.destroyed) { - record.outgoing.destroy(); - } - } catch (destroyErr) { - console.log(`[${record.id}] Error destroying outgoing socket: ${destroyErr}`); - } - } - - // Clear pendingData to avoid memory leaks - record.pendingData = []; - record.pendingDataSize = 0; - - // Remove the record from the tracking map - this.connectionRecords.delete(record.id); - - // Log connection details - if (this.settings.enableDetailedLogging) { - console.log( - `[${record.id}] Connection from ${record.remoteIP} on port ${record.localPort} terminated (${reason}).` + - ` Duration: ${plugins.prettyMs( - duration - )}, Bytes IN: ${bytesReceived}, OUT: ${bytesSent}, ` + - `TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${ - record.hasKeepAlive ? 'Yes' : 'No' - }` + - `${record.usingNetworkProxy ? ', Using NetworkProxy' : ''}` + - `${record.domainSwitches ? `, Domain switches: ${record.domainSwitches}` : ''}` - ); - } else { - console.log( - `[${record.id}] Connection from ${record.remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}` - ); - } - } - } - - /** - * Update connection activity timestamp - */ - private updateActivity(record: IConnectionRecord): void { - record.lastActivity = Date.now(); - - // Clear any inactivity warning - if (record.inactivityWarningIssued) { - record.inactivityWarningIssued = false; - } - } - - /** - * Get target IP with round-robin support - */ - private getTargetIP(domainConfig: IDomainConfig): string { - if (domainConfig.targetIPs && domainConfig.targetIPs.length > 0) { - const currentIndex = this.domainTargetIndices.get(domainConfig) || 0; - const ip = domainConfig.targetIPs[currentIndex % domainConfig.targetIPs.length]; - this.domainTargetIndices.set(domainConfig, currentIndex + 1); - return ip; - } - return this.settings.targetIP!; - } - - /** - * Initiates cleanup once for a connection - */ - private initiateCleanupOnce(record: IConnectionRecord, reason: string = 'normal'): void { - if (this.settings.enableDetailedLogging) { - console.log(`[${record.id}] Connection cleanup initiated for ${record.remoteIP} (${reason})`); - } - - if ( - record.incomingTerminationReason === null || - record.incomingTerminationReason === undefined - ) { - record.incomingTerminationReason = reason; - this.incrementTerminationStat('incoming', reason); - } - - this.cleanupConnection(record, reason); - } - - /** - * Creates a generic error handler for incoming or outgoing sockets - */ - private handleError(side: 'incoming' | 'outgoing', record: IConnectionRecord) { - return (err: Error) => { - const code = (err as any).code; - let reason = 'error'; - - const now = Date.now(); - const connectionDuration = now - record.incomingStartTime; - const lastActivityAge = now - record.lastActivity; - - if (code === 'ECONNRESET') { - reason = 'econnreset'; - console.log( - `[${record.id}] ECONNRESET on ${side} side from ${record.remoteIP}: ${ - err.message - }. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs( - lastActivityAge - )} ago` - ); - } else if (code === 'ETIMEDOUT') { - reason = 'etimedout'; - console.log( - `[${record.id}] ETIMEDOUT on ${side} side from ${record.remoteIP}: ${ - err.message - }. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs( - lastActivityAge - )} ago` - ); - } else { - console.log( - `[${record.id}] Error on ${side} side from ${record.remoteIP}: ${ - err.message - }. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs( - lastActivityAge - )} ago` - ); - } - - if (side === 'incoming' && record.incomingTerminationReason === null) { - record.incomingTerminationReason = reason; - this.incrementTerminationStat('incoming', reason); - } else if (side === 'outgoing' && record.outgoingTerminationReason === null) { - record.outgoingTerminationReason = reason; - this.incrementTerminationStat('outgoing', reason); - } - - this.initiateCleanupOnce(record, reason); - }; - } - - /** - * Creates a generic close handler for incoming or outgoing sockets - */ - private handleClose(side: 'incoming' | 'outgoing', record: IConnectionRecord) { - return () => { - if (this.settings.enableDetailedLogging) { - console.log(`[${record.id}] Connection closed on ${side} side from ${record.remoteIP}`); - } - - if (side === 'incoming' && record.incomingTerminationReason === null) { - record.incomingTerminationReason = 'normal'; - this.incrementTerminationStat('incoming', 'normal'); - } else if (side === 'outgoing' && record.outgoingTerminationReason === null) { - record.outgoingTerminationReason = 'normal'; - this.incrementTerminationStat('outgoing', 'normal'); - // Record the time when outgoing socket closed. - record.outgoingClosedTime = Date.now(); - } - - this.initiateCleanupOnce(record, 'closed_' + side); - }; - } - - /** - * Main method to start the proxy - */ - public async start() { - // Don't start if already shutting down - if (this.isShuttingDown) { - console.log("Cannot start PortProxy while it's shutting down"); - return; - } - - // Initialize NetworkProxy if needed (useNetworkProxy is set but networkProxy isn't initialized) - if ( - this.settings.useNetworkProxy && - this.settings.useNetworkProxy.length > 0 && - !this.networkProxy - ) { - await this.initializeNetworkProxy(); - } - - // Start NetworkProxy if configured - if (this.networkProxy) { - await this.networkProxy.start(); - console.log(`NetworkProxy started on port ${this.settings.networkProxyPort}`); - - // Log ACME status - if (this.settings.acme?.enabled) { - console.log( - `ACME certificate management is enabled (${ - this.settings.acme.useProduction ? 'Production' : 'Staging' - } mode)` - ); - console.log(`ACME HTTP challenge server on port ${this.settings.acme.port}`); - - // Register domains for ACME certificates if enabled - if (this.networkProxy.options.acme?.enabled) { - console.log('Registering domains with ACME certificate manager...'); - // The NetworkProxy will handle this internally via registerDomainsWithAcmeManager() - } - } - } - - // Define a unified connection handler for all listening ports. - const connectionHandler = (socket: plugins.net.Socket) => { - if (this.isShuttingDown) { - socket.end(); - socket.destroy(); - return; - } - - const remoteIP = socket.remoteAddress || ''; - const localPort = socket.localPort || 0; // The port on which this connection was accepted. - - // Check rate limits - if ( - this.settings.maxConnectionsPerIP && - this.getConnectionCountByIP(remoteIP) >= this.settings.maxConnectionsPerIP - ) { - console.log( - `Connection rejected from ${remoteIP}: Maximum connections per IP (${this.settings.maxConnectionsPerIP}) exceeded` - ); - socket.end(); - socket.destroy(); - return; - } - - if (this.settings.connectionRateLimitPerMinute && !this.checkConnectionRate(remoteIP)) { - console.log( - `Connection rejected from ${remoteIP}: Connection rate limit (${this.settings.connectionRateLimitPerMinute}/min) exceeded` - ); - socket.end(); - socket.destroy(); - return; - } - - // Apply socket optimizations - socket.setNoDelay(this.settings.noDelay); - - // Create a unique connection ID and record - const connectionId = generateConnectionId(); - const connectionRecord: IConnectionRecord = { - id: connectionId, - incoming: socket, - outgoing: null, - incomingStartTime: Date.now(), - lastActivity: Date.now(), - connectionClosed: false, - pendingData: [], - pendingDataSize: 0, - - // Initialize enhanced tracking fields - bytesReceived: 0, - bytesSent: 0, - remoteIP: remoteIP, - localPort: localPort, - isTLS: false, - tlsHandshakeComplete: false, - hasReceivedInitialData: false, - hasKeepAlive: false, // Will set to true if keep-alive is applied - incomingTerminationReason: null, - outgoingTerminationReason: null, - - // Initialize NetworkProxy tracking - usingNetworkProxy: false, - - // Initialize browser connection tracking - isBrowserConnection: false, - domainSwitches: 0, - }; - - // Apply keep-alive settings if enabled - if (this.settings.keepAlive) { - socket.setKeepAlive(true, this.settings.keepAliveInitialDelay); - connectionRecord.hasKeepAlive = true; // Mark connection as having keep-alive - - // Apply enhanced TCP keep-alive options if enabled - if (this.settings.enableKeepAliveProbes) { - try { - // These are platform-specific and may not be available - if ('setKeepAliveProbes' in socket) { - (socket as any).setKeepAliveProbes(10); // More aggressive probing - } - if ('setKeepAliveInterval' in socket) { - (socket as any).setKeepAliveInterval(1000); // 1 second interval between probes - } - } catch (err) { - // Ignore errors - these are optional enhancements - if (this.settings.enableDetailedLogging) { - console.log( - `[${connectionId}] Enhanced TCP keep-alive settings not supported: ${err}` - ); - } - } - } - } - - // Track connection by IP - this.trackConnectionByIP(remoteIP, connectionId); - this.connectionRecords.set(connectionId, connectionRecord); - - if (this.settings.enableDetailedLogging) { - console.log( - `[${connectionId}] New connection from ${remoteIP} on port ${localPort}. ` + - `Keep-Alive: ${connectionRecord.hasKeepAlive ? 'Enabled' : 'Disabled'}. ` + - `Active connections: ${this.connectionRecords.size}` - ); - } else { - console.log( - `New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionRecords.size}` - ); - } - - // Check if this connection should be forwarded directly to NetworkProxy - // First check port-based forwarding settings - let shouldUseNetworkProxy = - this.settings.useNetworkProxy && this.settings.useNetworkProxy.includes(localPort); - - // We'll look for domain-specific settings after SNI extraction - - if (shouldUseNetworkProxy) { - // For NetworkProxy ports, we want to capture the TLS handshake and forward directly - let initialDataReceived = false; - - // Set an initial timeout for handshake data - let initialTimeout: NodeJS.Timeout | null = setTimeout(() => { - if (!initialDataReceived) { - console.log(`[${connectionId}] Initial data warning (${this.settings.initialDataTimeout}ms) for connection from ${remoteIP}`); - - // Add a grace period instead of immediate termination - setTimeout(() => { - if (!initialDataReceived) { - console.log(`[${connectionId}] Final initial data timeout after grace period`); - if (connectionRecord.incomingTerminationReason === null) { - connectionRecord.incomingTerminationReason = 'initial_timeout'; - this.incrementTerminationStat('incoming', 'initial_timeout'); - } - socket.end(); - this.cleanupConnection(connectionRecord, 'initial_timeout'); - } - }, 30000); // 30 second grace period - } - }, this.settings.initialDataTimeout!); - - // Make sure timeout doesn't keep the process alive - if (initialTimeout.unref) { - initialTimeout.unref(); - } - - socket.on('error', this.handleError('incoming', connectionRecord)); - - // First data handler to capture initial TLS handshake for NetworkProxy - socket.once('data', (chunk: Buffer) => { - // Clear the initial timeout since we've received data - if (initialTimeout) { - clearTimeout(initialTimeout); - initialTimeout = null; - } - - initialDataReceived = true; - connectionRecord.hasReceivedInitialData = true; - - // Block non-TLS connections on port 443 - // Always enforce TLS on standard HTTPS port - if (!SniHandler.isTlsHandshake(chunk) && localPort === 443) { - console.log( - `[${connectionId}] Non-TLS connection detected on port 443. ` + - `Terminating connection - only TLS traffic is allowed on standard HTTPS port.` - ); - if (connectionRecord.incomingTerminationReason === null) { - connectionRecord.incomingTerminationReason = 'non_tls_blocked'; - this.incrementTerminationStat('incoming', 'non_tls_blocked'); - } - socket.end(); - this.cleanupConnection(connectionRecord, 'non_tls_blocked'); - return; - } - - // Check if this looks like a TLS handshake - if (SniHandler.isTlsHandshake(chunk)) { - connectionRecord.isTLS = true; - - // Check for TLS ClientHello with either no SNI or session tickets - if (this.settings.allowSessionTicket === false && SniHandler.isClientHello(chunk)) { - // Extract SNI first - const extractedSNI = SniHandler.extractSNI( - chunk, - this.settings.enableTlsDebugLogging - ); - const hasSNI = !!extractedSNI; - - // Analyze for session resumption attempt - const resumptionInfo = SniHandler.hasSessionResumption( - chunk, - this.settings.enableTlsDebugLogging - ); - - // Always log for debugging purposes - console.log( - `[${connectionId}] TLS ClientHello detected with allowSessionTicket=false. ` + - `Has SNI: ${hasSNI ? 'Yes' : 'No'}, ` + - `SNI value: ${extractedSNI || 'None'}, ` + - `Has session resumption: ${resumptionInfo.isResumption ? 'Yes' : 'No'}` - ); - - // Block if this is a connection with session resumption but no SNI - if (resumptionInfo.isResumption && !hasSNI) { - console.log( - `[${connectionId}] Session resumption detected in initial ClientHello without SNI and allowSessionTicket=false. ` + - `Terminating connection to force new TLS handshake.` - ); - if (connectionRecord.incomingTerminationReason === null) { - connectionRecord.incomingTerminationReason = 'session_ticket_blocked'; - this.incrementTerminationStat('incoming', 'session_ticket_blocked'); - } - socket.end(); - this.cleanupConnection(connectionRecord, 'session_ticket_blocked'); - return; - } - - // Also block if this is a TLS connection without SNI when allowSessionTicket is false - // This forces clients to send SNI which helps with routing - if (!hasSNI && localPort === 443) { - console.log( - `[${connectionId}] TLS ClientHello detected on port 443 without SNI and allowSessionTicket=false. ` + - `Terminating connection to force proper SNI in handshake.` - ); - if (connectionRecord.incomingTerminationReason === null) { - connectionRecord.incomingTerminationReason = 'no_sni_blocked'; - this.incrementTerminationStat('incoming', 'no_sni_blocked'); - } - socket.end(); - this.cleanupConnection(connectionRecord, 'no_sni_blocked'); - return; - } - } - - // Try to extract SNI for domain-specific NetworkProxy handling - const connInfo = { - sourceIp: remoteIP, - sourcePort: socket.remotePort || 0, - destIp: socket.localAddress || '', - destPort: socket.localPort || 0, - }; - - // Extract SNI to check for domain-specific NetworkProxy settings - const serverName = SniHandler.processTlsPacket( - chunk, - connInfo, - this.settings.enableTlsDebugLogging - ); - - if (serverName) { - // If we got an SNI, check for domain-specific NetworkProxy settings - const domainConfig = this.settings.domainConfigs.find((config) => - config.domains.some((d) => plugins.minimatch(serverName, d)) - ); - - // Save domain config and SNI in connection record - connectionRecord.domainConfig = domainConfig; - connectionRecord.lockedDomain = serverName; - - // Use domain-specific NetworkProxy port if configured - if (domainConfig?.useNetworkProxy) { - const networkProxyPort = - domainConfig.networkProxyPort || this.settings.networkProxyPort; - - if (this.settings.enableDetailedLogging) { - console.log( - `[${connectionId}] Using domain-specific NetworkProxy for ${serverName} on port ${networkProxyPort}` - ); - } - - // Forward to NetworkProxy with domain-specific port - this.forwardToNetworkProxy( - connectionId, - socket, - connectionRecord, - chunk, - networkProxyPort - ); - return; - } - } - - // Forward directly to NetworkProxy without domain-specific settings - this.forwardToNetworkProxy(connectionId, socket, connectionRecord, chunk); - } else { - // If not TLS, use normal direct connection - console.log(`[${connectionId}] Non-TLS connection on NetworkProxy port ${localPort}`); - this.setupDirectConnection( - connectionId, - socket, - connectionRecord, - undefined, - undefined, - chunk - ); - } - }); - } else { - // For non-NetworkProxy ports, proceed with normal processing - - // Define helpers for rejecting connections - const rejectIncomingConnection = (reason: string, logMessage: string) => { - console.log(`[${connectionId}] ${logMessage}`); - socket.end(); - if (connectionRecord.incomingTerminationReason === null) { - connectionRecord.incomingTerminationReason = reason; - this.incrementTerminationStat('incoming', reason); - } - this.cleanupConnection(connectionRecord, reason); - }; - - let initialDataReceived = false; - - // Set an initial timeout for SNI data if needed - let initialTimeout: NodeJS.Timeout | null = null; - if (this.settings.sniEnabled) { - initialTimeout = setTimeout(() => { - if (!initialDataReceived) { - console.log(`[${connectionId}] Initial data warning (${this.settings.initialDataTimeout}ms) for connection from ${remoteIP}`); - - // Add a grace period instead of immediate termination - setTimeout(() => { - if (!initialDataReceived) { - console.log(`[${connectionId}] Final initial data timeout after grace period`); - if (connectionRecord.incomingTerminationReason === null) { - connectionRecord.incomingTerminationReason = 'initial_timeout'; - this.incrementTerminationStat('incoming', 'initial_timeout'); - } - socket.end(); - this.cleanupConnection(connectionRecord, 'initial_timeout'); - } - }, 30000); // 30 second grace period - } - }, this.settings.initialDataTimeout!); - - // Make sure timeout doesn't keep the process alive - if (initialTimeout.unref) { - initialTimeout.unref(); - } - } else { - initialDataReceived = true; - connectionRecord.hasReceivedInitialData = true; - } - - socket.on('error', this.handleError('incoming', connectionRecord)); - - // Track data for bytes counting - socket.on('data', (chunk: Buffer) => { - connectionRecord.bytesReceived += chunk.length; - this.updateActivity(connectionRecord); - - // Check for TLS handshake if this is the first chunk - if (!connectionRecord.isTLS && SniHandler.isTlsHandshake(chunk)) { - connectionRecord.isTLS = true; - - if (this.settings.enableTlsDebugLogging) { - console.log( - `[${connectionId}] TLS handshake detected from ${remoteIP}, ${chunk.length} bytes` - ); - // Try to extract SNI and log detailed debug info - // Create connection info for debug logging - const debugConnInfo = { - sourceIp: remoteIP, - sourcePort: socket.remotePort || 0, - destIp: socket.localAddress || '', - destPort: socket.localPort || 0, - }; - - SniHandler.extractSNIWithResumptionSupport(chunk, debugConnInfo, true); - } - } - }); - - /** - * 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. - */ - const setupConnection = ( - serverName: string, - initialChunk?: Buffer, - forcedDomain?: IDomainConfig, - overridePort?: number - ) => { - // Clear the initial timeout since we've received data - if (initialTimeout) { - clearTimeout(initialTimeout); - initialTimeout = null; - } - - // Mark that we've received initial data - initialDataReceived = true; - connectionRecord.hasReceivedInitialData = true; - - // Check if this looks like a TLS handshake - const isTlsHandshakeDetected = initialChunk && SniHandler.isTlsHandshake(initialChunk); - if (isTlsHandshakeDetected) { - connectionRecord.isTLS = true; - - if (this.settings.enableTlsDebugLogging) { - console.log( - `[${connectionId}] TLS handshake detected in setup, ${initialChunk.length} bytes` - ); - } - } - - // If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup. - const domainConfig = forcedDomain - ? forcedDomain - : serverName - ? this.settings.domainConfigs.find((config) => - config.domains.some((d) => plugins.minimatch(serverName, d)) - ) - : undefined; - - // Save domain config in connection record - connectionRecord.domainConfig = domainConfig; - - // Check if this domain should use NetworkProxy (domain-specific setting) - if (domainConfig?.useNetworkProxy && this.networkProxy) { - if (this.settings.enableDetailedLogging) { - console.log( - `[${connectionId}] Domain ${serverName} is configured to use NetworkProxy` - ); - } - - const networkProxyPort = - domainConfig.networkProxyPort || this.settings.networkProxyPort; - - if (initialChunk && connectionRecord.isTLS) { - // For TLS connections with initial chunk, forward to NetworkProxy - this.forwardToNetworkProxy( - connectionId, - socket, - connectionRecord, - initialChunk, - networkProxyPort // Pass the domain-specific NetworkProxy port if configured - ); - return; // Skip normal connection setup - } - } - - // IP validation is skipped if allowedIPs is empty - if (domainConfig) { - const effectiveAllowedIPs: string[] = [ - ...domainConfig.allowedIPs, - ...(this.settings.defaultAllowedIPs || []), - ]; - const effectiveBlockedIPs: string[] = [ - ...(domainConfig.blockedIPs || []), - ...(this.settings.defaultBlockedIPs || []), - ]; - - // Skip IP validation if allowedIPs is empty - if ( - domainConfig.allowedIPs.length > 0 && - !isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs) - ) { - return rejectIncomingConnection( - 'rejected', - `Connection rejected: IP ${remoteIP} not allowed for domain ${domainConfig.domains.join( - ', ' - )}` - ); - } - } else if ( - this.settings.defaultAllowedIPs && - this.settings.defaultAllowedIPs.length > 0 - ) { - if ( - !isGlobIPAllowed( - remoteIP, - this.settings.defaultAllowedIPs, - this.settings.defaultBlockedIPs || [] - ) - ) { - return rejectIncomingConnection( - 'rejected', - `Connection rejected: IP ${remoteIP} not allowed by default allowed list` - ); - } - } - - // Save the initial SNI - if (serverName) { - connectionRecord.lockedDomain = serverName; - } - - // Set up the direct connection - return this.setupDirectConnection( - connectionId, - socket, - connectionRecord, - domainConfig, - serverName, - initialChunk, - overridePort - ); - }; - - // --- 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 && - this.settings.defaultAllowedIPs.length > 0 && - !isAllowed(remoteIP, this.settings.defaultAllowedIPs) - ) { - console.log( - `[${connectionId}] Connection from ${remoteIP} rejected: IP ${remoteIP} not allowed in global default allowed list.` - ); - socket.end(); - return; - } - if (this.settings.enableDetailedLogging) { - console.log( - `[${connectionId}] Port-based connection from ${remoteIP} on port ${localPort} forwarded to global target IP ${this.settings.targetIP}.` - ); - } - setupConnection( - '', - undefined, - { - domains: ['global'], - allowedIPs: this.settings.defaultAllowedIPs || [], - blockedIPs: this.settings.defaultBlockedIPs || [], - targetIPs: [this.settings.targetIP!], - portRanges: [], - }, - localPort - ); - return; - } else { - // Attempt to find a matching forced domain config based on the local port. - const forcedDomain = this.settings.domainConfigs.find( - (domain) => - domain.portRanges && - domain.portRanges.length > 0 && - isPortInRanges(localPort, domain.portRanges) - ); - if (forcedDomain) { - const effectiveAllowedIPs: string[] = [ - ...forcedDomain.allowedIPs, - ...(this.settings.defaultAllowedIPs || []), - ]; - const effectiveBlockedIPs: string[] = [ - ...(forcedDomain.blockedIPs || []), - ...(this.settings.defaultBlockedIPs || []), - ]; - if (!isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) { - console.log( - `[${connectionId}] Connection from ${remoteIP} rejected: IP not allowed for domain ${forcedDomain.domains.join( - ', ' - )} on port ${localPort}.` - ); - socket.end(); - return; - } - if (this.settings.enableDetailedLogging) { - console.log( - `[${connectionId}] Port-based connection from ${remoteIP} on port ${localPort} matched domain ${forcedDomain.domains.join( - ', ' - )}.` - ); - } - 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) { - initialDataReceived = false; - - socket.once('data', (chunk: Buffer) => { - // Clear timeout immediately - if (initialTimeout) { - clearTimeout(initialTimeout); - initialTimeout = null; - } - - initialDataReceived = true; - - // Add debugging ONLY if detailed logging is enabled - avoid heavy processing - if (this.settings.enableTlsDebugLogging && SniHandler.isClientHello(chunk)) { - // Move heavy debug logging to a separate async task to not block the flow - setImmediate(() => { - try { - const resumptionInfo = SniHandler.hasSessionResumption(chunk, true); - const standardSNI = SniHandler.extractSNI(chunk, true); - const pskSNI = SniHandler.extractSNIFromPSKExtension(chunk, true); - - console.log(`[${connectionId}] ClientHello details: isResumption=${resumptionInfo.isResumption}, hasSNI=${resumptionInfo.hasSNI}`); - console.log(`[${connectionId}] SNI extraction results: standardSNI=${standardSNI || 'none'}, pskSNI=${pskSNI || 'none'}`); - } catch (err) { - console.log(`[${connectionId}] Error in debug logging: ${err}`); - } - }); - } - - // Block non-TLS connections on port 443 - // Always enforce TLS on standard HTTPS port - if (!SniHandler.isTlsHandshake(chunk) && localPort === 443) { - console.log( - `[${connectionId}] Non-TLS connection detected on port 443 in SNI handler. ` + - `Terminating connection - only TLS traffic is allowed on standard HTTPS port.` - ); - if (connectionRecord.incomingTerminationReason === null) { - connectionRecord.incomingTerminationReason = 'non_tls_blocked'; - this.incrementTerminationStat('incoming', 'non_tls_blocked'); - } - socket.end(); - this.cleanupConnection(connectionRecord, 'non_tls_blocked'); - return; - } - - // Try to extract SNI - let serverName = ''; - - if (SniHandler.isTlsHandshake(chunk)) { - connectionRecord.isTLS = true; - - if (this.settings.enableTlsDebugLogging) { - console.log( - `[${connectionId}] Extracting SNI from TLS handshake, ${chunk.length} bytes` - ); - } - - // Check for session tickets if allowSessionTicket is disabled - if (this.settings.allowSessionTicket === false && SniHandler.isClientHello(chunk)) { - // Analyze for session resumption attempt - const resumptionInfo = SniHandler.hasSessionResumption( - chunk, - this.settings.enableTlsDebugLogging - ); - - if (resumptionInfo.isResumption) { - // Always log resumption attempt for easier debugging - // Try to extract SNI for logging - const extractedSNI = SniHandler.extractSNI( - chunk, - this.settings.enableTlsDebugLogging - ); - console.log( - `[${connectionId}] Session resumption detected in SNI handler. ` + - `Has SNI: ${resumptionInfo.hasSNI ? 'Yes' : 'No'}, ` + - `SNI value: ${extractedSNI || 'None'}, ` + - `allowSessionTicket: ${this.settings.allowSessionTicket}` - ); - - // Block if there's session resumption without SNI - if (!resumptionInfo.hasSNI) { - console.log( - `[${connectionId}] Session resumption detected in SNI handler without SNI and allowSessionTicket=false. ` + - `Terminating connection to force new TLS handshake.` - ); - if (connectionRecord.incomingTerminationReason === null) { - connectionRecord.incomingTerminationReason = 'session_ticket_blocked'; - this.incrementTerminationStat('incoming', 'session_ticket_blocked'); - } - socket.end(); - this.cleanupConnection(connectionRecord, 'session_ticket_blocked'); - return; - } else { - if (this.settings.enableDetailedLogging) { - console.log( - `[${connectionId}] Session resumption with SNI detected in SNI handler. ` + - `Allowing connection since SNI is present.` - ); - } - } - } - } - - // Create connection info object for SNI extraction - const connInfo = { - sourceIp: remoteIP, - sourcePort: socket.remotePort || 0, - destIp: socket.localAddress || '', - destPort: socket.localPort || 0, - }; - - // Use the new processTlsPacket method for comprehensive handling - serverName = - SniHandler.processTlsPacket( - chunk, - connInfo, - this.settings.enableTlsDebugLogging, - connectionRecord.lockedDomain // Pass any previously negotiated domain as a hint - ) || ''; - } - - // Lock the connection to the negotiated SNI. - connectionRecord.lockedDomain = serverName; - - if (this.settings.enableDetailedLogging) { - console.log( - `[${connectionId}] Received connection from ${remoteIP} with SNI: ${ - serverName || '(empty)' - }` - ); - } - - setupConnection(serverName, chunk); - }); - } else { - initialDataReceived = true; - connectionRecord.hasReceivedInitialData = true; - - if ( - this.settings.defaultAllowedIPs && - this.settings.defaultAllowedIPs.length > 0 && - !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, () => { - const isNetworkProxyPort = this.settings.useNetworkProxy?.includes(port); - console.log( - `PortProxy -> OK: Now listening on port ${port}${ - this.settings.sniEnabled && !isNetworkProxyPort ? ' (SNI passthrough enabled)' : '' - }${isNetworkProxyPort ? ' (NetworkProxy forwarding enabled)' : ''}` - ); - }); - this.netServers.push(server); - } - - // Log active connection count, longest running durations, and run parity checks periodically - this.connectionLogger = setInterval(() => { - // Immediately return if shutting down - if (this.isShuttingDown) return; - - const now = Date.now(); - let maxIncoming = 0; - let maxOutgoing = 0; - let tlsConnections = 0; - let nonTlsConnections = 0; - let completedTlsHandshakes = 0; - let pendingTlsHandshakes = 0; - let keepAliveConnections = 0; - let networkProxyConnections = 0; - let domainSwitchedConnections = 0; - - // Create a copy of the keys to avoid modification during iteration - const connectionIds = [...this.connectionRecords.keys()]; - - for (const id of connectionIds) { - const record = this.connectionRecords.get(id); - if (!record) continue; - - // Track connection stats - if (record.isTLS) { - tlsConnections++; - if (record.tlsHandshakeComplete) { - completedTlsHandshakes++; - } else { - pendingTlsHandshakes++; - } - } else { - nonTlsConnections++; - } - - if (record.hasKeepAlive) { - keepAliveConnections++; - } - - if (record.usingNetworkProxy) { - networkProxyConnections++; - } - - if (record.domainSwitches && record.domainSwitches > 0) { - domainSwitchedConnections++; - } - - maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime); - if (record.outgoingStartTime) { - maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime); - } - // Parity check: if outgoing socket closed and incoming remains active - if ( - record.outgoingClosedTime && - !record.incoming.destroyed && - !record.connectionClosed && - now - record.outgoingClosedTime > 120000 - ) { - const remoteIP = record.remoteIP; - console.log( - `[${id}] Parity check: Incoming socket for ${remoteIP} still active ${plugins.prettyMs( - now - record.outgoingClosedTime - )} after outgoing closed.` - ); - this.cleanupConnection(record, 'parity_check'); - } - - // Check for stalled connections waiting for initial data - if ( - !record.hasReceivedInitialData && - now - record.incomingStartTime > this.settings.initialDataTimeout! / 2 - ) { - console.log( - `[${id}] Warning: Connection from ${ - record.remoteIP - } has not received initial data after ${plugins.prettyMs( - now - record.incomingStartTime - )}` - ); - } - - // Skip inactivity check if disabled or for immortal keep-alive connections - if ( - !this.settings.disableInactivityCheck && - !(record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') - ) { - const inactivityTime = now - record.lastActivity; - - // Use extended timeout for extended-treatment keep-alive connections - let effectiveTimeout = this.settings.inactivityTimeout!; - if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') { - const multiplier = this.settings.keepAliveInactivityMultiplier || 6; - effectiveTimeout = effectiveTimeout * multiplier; - } - - if (inactivityTime > effectiveTimeout && !record.connectionClosed) { - // For keep-alive connections, issue a warning first - if (record.hasKeepAlive && !record.inactivityWarningIssued) { - console.log( - `[${id}] Warning: Keep-alive connection from ${ - record.remoteIP - } inactive for ${plugins.prettyMs(inactivityTime)}. ` + - `Will close in 10 minutes if no activity.` - ); - - // Set warning flag and add grace period - record.inactivityWarningIssued = true; - record.lastActivity = now - (effectiveTimeout - 600000); - - // Try to stimulate activity with a probe packet - if (record.outgoing && !record.outgoing.destroyed) { - try { - record.outgoing.write(Buffer.alloc(0)); - - if (this.settings.enableDetailedLogging) { - console.log(`[${id}] Sent probe packet to test keep-alive connection`); - } - } catch (err) { - console.log(`[${id}] Error sending probe packet: ${err}`); - } - } - } else { - // For non-keep-alive or after warning, close the connection - console.log( - `[${id}] Inactivity check: No activity on connection from ${record.remoteIP} ` + - `for ${plugins.prettyMs(inactivityTime)}.` + - (record.hasKeepAlive ? ' Despite keep-alive being enabled.' : '') - ); - this.cleanupConnection(record, 'inactivity'); - } - } else if (inactivityTime <= effectiveTimeout && record.inactivityWarningIssued) { - // If activity detected after warning, clear the warning - if (this.settings.enableDetailedLogging) { - console.log( - `[${id}] Connection activity detected after inactivity warning, resetting warning` - ); - } - record.inactivityWarningIssued = false; - } - } - } - - // Log detailed stats periodically - console.log( - `Active connections: ${this.connectionRecords.size}. ` + - `Types: TLS=${tlsConnections} (Completed=${completedTlsHandshakes}, Pending=${pendingTlsHandshakes}), ` + - `Non-TLS=${nonTlsConnections}, KeepAlive=${keepAliveConnections}, NetworkProxy=${networkProxyConnections}, ` + - `DomainSwitched=${domainSwitchedConnections}. ` + - `Longest running: IN=${plugins.prettyMs(maxIncoming)}, OUT=${plugins.prettyMs( - maxOutgoing - )}. ` + - `Termination stats: ${JSON.stringify({ - IN: this.terminationStats.incoming, - OUT: this.terminationStats.outgoing, - })}` - ); - }, this.settings.inactivityCheckInterval || 60000); - - // Make sure the interval doesn't keep the process alive - if (this.connectionLogger.unref) { - this.connectionLogger.unref(); - } - } - - /** - * Gracefully shut down the proxy - */ - public async stop() { - console.log('PortProxy shutting down...'); - this.isShuttingDown = true; - - // Stop accepting new connections - const closeServerPromises: Promise[] = this.netServers.map( - (server) => - new Promise((resolve) => { - if (!server.listening) { - resolve(); - return; - } - server.close((err) => { - if (err) { - console.log(`Error closing server: ${err.message}`); - } - resolve(); - }); - }) - ); - - // Stop the connection logger - if (this.connectionLogger) { - clearInterval(this.connectionLogger); - this.connectionLogger = null; - } - - // Wait for servers to close - await Promise.all(closeServerPromises); - console.log('All servers closed. Cleaning up active connections...'); - - // Force destroy all active connections immediately - const connectionIds = [...this.connectionRecords.keys()]; - console.log(`Cleaning up ${connectionIds.length} active connections...`); - - // First pass: End all connections gracefully - for (const id of connectionIds) { - const record = this.connectionRecords.get(id); - if (record) { - try { - // Clear any timers - if (record.cleanupTimer) { - clearTimeout(record.cleanupTimer); - record.cleanupTimer = undefined; - } - - // End sockets gracefully - if (record.incoming && !record.incoming.destroyed) { - record.incoming.end(); - } - - if (record.outgoing && !record.outgoing.destroyed) { - record.outgoing.end(); - } - } catch (err) { - console.log(`Error during graceful connection end for ${id}: ${err}`); - } - } - } - - // Short delay to allow graceful ends to process - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Second pass: Force destroy everything - for (const id of connectionIds) { - const record = this.connectionRecords.get(id); - if (record) { - try { - // Remove all listeners to prevent memory leaks - if (record.incoming) { - record.incoming.removeAllListeners(); - if (!record.incoming.destroyed) { - record.incoming.destroy(); - } - } - - if (record.outgoing) { - record.outgoing.removeAllListeners(); - if (!record.outgoing.destroyed) { - record.outgoing.destroy(); - } - } - } catch (err) { - console.log(`Error during forced connection destruction for ${id}: ${err}`); - } - } - } - - // Stop NetworkProxy if it was started (which also stops ACME manager) - if (this.networkProxy) { - try { - console.log('Stopping NetworkProxy...'); - await this.networkProxy.stop(); - console.log('NetworkProxy stopped successfully'); - - // Log ACME shutdown if it was enabled - if (this.settings.acme?.enabled) { - console.log('ACME certificate manager stopped'); - } - } catch (err) { - console.log(`Error stopping NetworkProxy: ${err}`); - } - } - - // Clear all tracking maps - this.connectionRecords.clear(); - this.domainTargetIndices.clear(); - this.connectionsByIP.clear(); - this.connectionRateByIP.clear(); - this.netServers = []; - - // Reset termination stats - this.terminationStats = { - incoming: {}, - outgoing: {}, - }; - - console.log('PortProxy shutdown complete.'); - } -} diff --git a/ts/classes.pp.acmemanager.ts b/ts/classes.pp.acmemanager.ts new file mode 100644 index 0000000..20dfd75 --- /dev/null +++ b/ts/classes.pp.acmemanager.ts @@ -0,0 +1,149 @@ +import type { IPortProxySettings } from './classes.pp.interfaces.js'; +import { NetworkProxyBridge } from './classes.pp.networkproxybridge.js'; + +/** + * Manages ACME certificate operations + */ +export class AcmeManager { + constructor( + private settings: IPortProxySettings, + private networkProxyBridge: NetworkProxyBridge + ) {} + + /** + * Get current ACME settings + */ + public getAcmeSettings(): IPortProxySettings['acme'] { + return this.settings.acme; + } + + /** + * Check if ACME is enabled + */ + public isAcmeEnabled(): boolean { + return !!this.settings.acme?.enabled; + } + + /** + * Update ACME certificate settings + */ + public async updateAcmeSettings(acmeSettings: IPortProxySettings['acme']): Promise { + console.log('Updating ACME certificate settings'); + + // Check if enabled state is changing + const enabledChanging = this.settings.acme?.enabled !== acmeSettings.enabled; + + // Update settings + this.settings.acme = { + ...this.settings.acme, + ...acmeSettings, + }; + + // Get NetworkProxy instance + const networkProxy = this.networkProxyBridge.getNetworkProxy(); + + if (!networkProxy) { + console.log('Cannot update ACME settings - NetworkProxy not initialized'); + return; + } + + try { + // If enabled state changed, we need to restart NetworkProxy + if (enabledChanging) { + console.log(`ACME enabled state changed to: ${acmeSettings.enabled}`); + + // Stop the current NetworkProxy + await this.networkProxyBridge.stop(); + + // Reinitialize with new settings + await this.networkProxyBridge.initialize(); + + // Start NetworkProxy with new settings + await this.networkProxyBridge.start(); + } else { + // Just update the settings in the existing NetworkProxy + console.log('Updating ACME settings in NetworkProxy without restart'); + + // Update settings in NetworkProxy + if (networkProxy.options && networkProxy.options.acme) { + networkProxy.options.acme = { ...this.settings.acme }; + + // For certificate renewals, we might want to trigger checks with the new settings + if (acmeSettings.renewThresholdDays !== undefined) { + console.log(`Setting new renewal threshold to ${acmeSettings.renewThresholdDays} days`); + networkProxy.options.acme.renewThresholdDays = acmeSettings.renewThresholdDays; + } + + // Update other settings that might affect certificate operations + if (acmeSettings.useProduction !== undefined) { + console.log(`Setting ACME to ${acmeSettings.useProduction ? 'production' : 'staging'} mode`); + } + + if (acmeSettings.autoRenew !== undefined) { + console.log(`Setting auto-renewal to ${acmeSettings.autoRenew ? 'enabled' : 'disabled'}`); + } + } + } + } catch (err) { + console.log(`Error updating ACME settings: ${err}`); + } + } + + /** + * Request a certificate for a specific domain + */ + public async requestCertificate(domain: string): Promise { + // Validate domain format + if (!this.isValidDomain(domain)) { + console.log(`Invalid domain format: ${domain}`); + return false; + } + + // Delegate to NetworkProxyManager + return this.networkProxyBridge.requestCertificate(domain); + } + + /** + * Basic domain validation + */ + private isValidDomain(domain: string): boolean { + // Very basic domain validation + if (!domain || domain.length === 0) { + return false; + } + + // Check for wildcard domains (they can't get ACME certs) + if (domain.includes('*')) { + console.log(`Wildcard domains like "${domain}" are not supported for ACME certificates`); + return false; + } + + // Check if domain has at least one dot and no invalid characters + const validDomainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + if (!validDomainRegex.test(domain)) { + console.log(`Domain "${domain}" has invalid format`); + return false; + } + + return true; + } + + /** + * Get eligible domains for ACME certificates + */ + public getEligibleDomains(): string[] { + // Collect all eligible domains from domain configs + const domains: string[] = []; + + for (const config of this.settings.domainConfigs) { + // Skip domains that can't be used with ACME + const eligibleDomains = config.domains.filter(domain => + !domain.includes('*') && this.isValidDomain(domain) + ); + + domains.push(...eligibleDomains); + } + + return domains; + } +} \ No newline at end of file diff --git a/ts/classes.pp.connectionhandler.ts b/ts/classes.pp.connectionhandler.ts new file mode 100644 index 0000000..30994c7 --- /dev/null +++ b/ts/classes.pp.connectionhandler.ts @@ -0,0 +1,982 @@ +import * as plugins from './plugins.js'; +import type { IConnectionRecord, IDomainConfig, IPortProxySettings } from './classes.pp.interfaces.js'; +import { ConnectionManager } from './classes.pp.connectionmanager.js'; +import { SecurityManager } from './classes.pp.securitymanager.js'; +import { DomainConfigManager } from './classes.pp.domainconfigmanager.js'; +import { TlsManager } from './classes.pp.tlsmanager.js'; +import { NetworkProxyBridge } from './classes.pp.networkproxybridge.js'; +import { TimeoutManager } from './classes.pp.timeoutmanager.js'; +import { PortRangeManager } from './classes.pp.portrangemanager.js'; + +/** + * Handles new connection processing and setup logic + */ +export class ConnectionHandler { + constructor( + private settings: IPortProxySettings, + private connectionManager: ConnectionManager, + private securityManager: SecurityManager, + private domainConfigManager: DomainConfigManager, + private tlsManager: TlsManager, + private networkProxyBridge: NetworkProxyBridge, + private timeoutManager: TimeoutManager, + private portRangeManager: PortRangeManager + ) {} + + /** + * Handle a new incoming connection + */ + public handleConnection(socket: plugins.net.Socket): void { + const remoteIP = socket.remoteAddress || ''; + const localPort = socket.localPort || 0; + + // Validate IP against rate limits and connection limits + const ipValidation = this.securityManager.validateIP(remoteIP); + if (!ipValidation.allowed) { + console.log(`Connection rejected from ${remoteIP}: ${ipValidation.reason}`); + socket.end(); + socket.destroy(); + return; + } + + // Create a new connection record + const record = this.connectionManager.createConnection(socket); + const connectionId = record.id; + + // Apply socket optimizations + socket.setNoDelay(this.settings.noDelay); + + // Apply keep-alive settings if enabled + if (this.settings.keepAlive) { + socket.setKeepAlive(true, this.settings.keepAliveInitialDelay); + record.hasKeepAlive = true; + + // Apply enhanced TCP keep-alive options if enabled + if (this.settings.enableKeepAliveProbes) { + try { + // These are platform-specific and may not be available + if ('setKeepAliveProbes' in socket) { + (socket as any).setKeepAliveProbes(10); + } + if ('setKeepAliveInterval' in socket) { + (socket as any).setKeepAliveInterval(1000); + } + } catch (err) { + // Ignore errors - these are optional enhancements + if (this.settings.enableDetailedLogging) { + console.log(`[${connectionId}] Enhanced TCP keep-alive settings not supported: ${err}`); + } + } + } + } + + if (this.settings.enableDetailedLogging) { + console.log( + `[${connectionId}] New connection from ${remoteIP} on port ${localPort}. ` + + `Keep-Alive: ${record.hasKeepAlive ? 'Enabled' : 'Disabled'}. ` + + `Active connections: ${this.connectionManager.getConnectionCount()}` + ); + } else { + console.log( + `New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionManager.getConnectionCount()}` + ); + } + + // Check if this connection should be forwarded directly to NetworkProxy + if (this.portRangeManager.shouldUseNetworkProxy(localPort)) { + this.handleNetworkProxyConnection(socket, record); + } else { + // For non-NetworkProxy ports, proceed with normal processing + this.handleStandardConnection(socket, record); + } + } + + /** + * Handle a connection that should be forwarded to NetworkProxy + */ + private handleNetworkProxyConnection(socket: plugins.net.Socket, record: IConnectionRecord): void { + const connectionId = record.id; + let initialDataReceived = false; + + // Set an initial timeout for handshake data + let initialTimeout: NodeJS.Timeout | null = setTimeout(() => { + if (!initialDataReceived) { + console.log( + `[${connectionId}] Initial data warning (${this.settings.initialDataTimeout}ms) for connection from ${record.remoteIP}` + ); + + // Add a grace period instead of immediate termination + setTimeout(() => { + if (!initialDataReceived) { + console.log(`[${connectionId}] Final initial data timeout after grace period`); + if (record.incomingTerminationReason === null) { + record.incomingTerminationReason = 'initial_timeout'; + this.connectionManager.incrementTerminationStat('incoming', 'initial_timeout'); + } + socket.end(); + this.connectionManager.cleanupConnection(record, 'initial_timeout'); + } + }, 30000); // 30 second grace period + } + }, this.settings.initialDataTimeout!); + + // Make sure timeout doesn't keep the process alive + if (initialTimeout.unref) { + initialTimeout.unref(); + } + + // Set up error handler + socket.on('error', this.connectionManager.handleError('incoming', record)); + + // First data handler to capture initial TLS handshake for NetworkProxy + socket.once('data', (chunk: Buffer) => { + // Clear the initial timeout since we've received data + if (initialTimeout) { + clearTimeout(initialTimeout); + initialTimeout = null; + } + + initialDataReceived = true; + record.hasReceivedInitialData = true; + + // Block non-TLS connections on port 443 + const localPort = record.localPort; + if (!this.tlsManager.isTlsHandshake(chunk) && localPort === 443) { + console.log( + `[${connectionId}] Non-TLS connection detected on port 443. ` + + `Terminating connection - only TLS traffic is allowed on standard HTTPS port.` + ); + if (record.incomingTerminationReason === null) { + record.incomingTerminationReason = 'non_tls_blocked'; + this.connectionManager.incrementTerminationStat('incoming', 'non_tls_blocked'); + } + socket.end(); + this.connectionManager.cleanupConnection(record, 'non_tls_blocked'); + return; + } + + // Check if this looks like a TLS handshake + if (this.tlsManager.isTlsHandshake(chunk)) { + record.isTLS = true; + + // Check session tickets if they're disabled + if (this.settings.allowSessionTicket === false && this.tlsManager.isClientHello(chunk)) { + // Create connection info for SNI extraction + const connInfo = { + sourceIp: record.remoteIP, + sourcePort: socket.remotePort || 0, + destIp: socket.localAddress || '', + destPort: socket.localPort || 0, + }; + + // Extract SNI for domain-specific NetworkProxy handling + const serverName = this.tlsManager.extractSNI(chunk, connInfo); + + if (serverName) { + // If we got an SNI, check for domain-specific NetworkProxy settings + const domainConfig = this.domainConfigManager.findDomainConfig(serverName); + + // Save domain config and SNI in connection record + record.domainConfig = domainConfig; + record.lockedDomain = serverName; + + // Use domain-specific NetworkProxy port if configured + if (domainConfig && this.domainConfigManager.shouldUseNetworkProxy(domainConfig)) { + const networkProxyPort = this.domainConfigManager.getNetworkProxyPort(domainConfig); + + if (this.settings.enableDetailedLogging) { + console.log( + `[${connectionId}] Using domain-specific NetworkProxy for ${serverName} on port ${networkProxyPort}` + ); + } + + // Forward to NetworkProxy with domain-specific port + this.networkProxyBridge.forwardToNetworkProxy( + connectionId, + socket, + record, + chunk, + networkProxyPort, + (reason) => this.connectionManager.initiateCleanupOnce(record, reason) + ); + return; + } + } + } + + // Forward directly to NetworkProxy without domain-specific settings + this.networkProxyBridge.forwardToNetworkProxy( + connectionId, + socket, + record, + chunk, + undefined, + (reason) => this.connectionManager.initiateCleanupOnce(record, reason) + ); + } else { + // If not TLS, use normal direct connection + console.log(`[${connectionId}] Non-TLS connection on NetworkProxy port ${record.localPort}`); + this.setupDirectConnection( + socket, + record, + undefined, + undefined, + chunk + ); + } + }); + } + + /** + * Handle a standard (non-NetworkProxy) connection + */ + private handleStandardConnection(socket: plugins.net.Socket, record: IConnectionRecord): void { + const connectionId = record.id; + const localPort = record.localPort; + + // Define helpers for rejecting connections + const rejectIncomingConnection = (reason: string, logMessage: string) => { + console.log(`[${connectionId}] ${logMessage}`); + socket.end(); + if (record.incomingTerminationReason === null) { + record.incomingTerminationReason = reason; + this.connectionManager.incrementTerminationStat('incoming', reason); + } + this.connectionManager.cleanupConnection(record, reason); + }; + + let initialDataReceived = false; + + // Set an initial timeout for SNI data if needed + let initialTimeout: NodeJS.Timeout | null = null; + if (this.settings.sniEnabled) { + initialTimeout = setTimeout(() => { + if (!initialDataReceived) { + console.log( + `[${connectionId}] Initial data warning (${this.settings.initialDataTimeout}ms) for connection from ${record.remoteIP}` + ); + + // Add a grace period instead of immediate termination + setTimeout(() => { + if (!initialDataReceived) { + console.log(`[${connectionId}] Final initial data timeout after grace period`); + if (record.incomingTerminationReason === null) { + record.incomingTerminationReason = 'initial_timeout'; + this.connectionManager.incrementTerminationStat('incoming', 'initial_timeout'); + } + socket.end(); + this.connectionManager.cleanupConnection(record, 'initial_timeout'); + } + }, 30000); // 30 second grace period + } + }, this.settings.initialDataTimeout!); + + // Make sure timeout doesn't keep the process alive + if (initialTimeout.unref) { + initialTimeout.unref(); + } + } else { + initialDataReceived = true; + record.hasReceivedInitialData = true; + } + + socket.on('error', this.connectionManager.handleError('incoming', record)); + + // Track data for bytes counting + socket.on('data', (chunk: Buffer) => { + record.bytesReceived += chunk.length; + this.timeoutManager.updateActivity(record); + + // Check for TLS handshake if this is the first chunk + if (!record.isTLS && this.tlsManager.isTlsHandshake(chunk)) { + record.isTLS = true; + + if (this.settings.enableTlsDebugLogging) { + console.log( + `[${connectionId}] TLS handshake detected from ${record.remoteIP}, ${chunk.length} bytes` + ); + } + } + }); + + /** + * Sets up the connection to the target host. + */ + const setupConnection = ( + serverName: string, + initialChunk?: Buffer, + forcedDomain?: IDomainConfig, + overridePort?: number + ) => { + // Clear the initial timeout since we've received data + if (initialTimeout) { + clearTimeout(initialTimeout); + initialTimeout = null; + } + + // Mark that we've received initial data + initialDataReceived = true; + record.hasReceivedInitialData = true; + + // Check if this looks like a TLS handshake + if (initialChunk && this.tlsManager.isTlsHandshake(initialChunk)) { + record.isTLS = true; + + if (this.settings.enableTlsDebugLogging) { + console.log( + `[${connectionId}] TLS handshake detected in setup, ${initialChunk.length} bytes` + ); + } + } + + // If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup. + const domainConfig = forcedDomain + ? forcedDomain + : serverName + ? this.domainConfigManager.findDomainConfig(serverName) + : undefined; + + // Save domain config in connection record + record.domainConfig = domainConfig; + + // Check if this domain should use NetworkProxy (domain-specific setting) + if (domainConfig && + this.domainConfigManager.shouldUseNetworkProxy(domainConfig) && + this.networkProxyBridge.getNetworkProxy()) { + + if (this.settings.enableDetailedLogging) { + console.log( + `[${connectionId}] Domain ${serverName} is configured to use NetworkProxy` + ); + } + + const networkProxyPort = this.domainConfigManager.getNetworkProxyPort(domainConfig); + + if (initialChunk && record.isTLS) { + // For TLS connections with initial chunk, forward to NetworkProxy + this.networkProxyBridge.forwardToNetworkProxy( + connectionId, + socket, + record, + initialChunk, + networkProxyPort, + (reason) => this.connectionManager.initiateCleanupOnce(record, reason) + ); + return; // Skip normal connection setup + } + } + + // IP validation + if (domainConfig) { + const ipRules = this.domainConfigManager.getEffectiveIPRules(domainConfig); + + // Skip IP validation if allowedIPs is empty + if ( + domainConfig.allowedIPs.length > 0 && + !this.securityManager.isIPAuthorized(record.remoteIP, ipRules.allowedIPs, ipRules.blockedIPs) + ) { + return rejectIncomingConnection( + 'rejected', + `Connection rejected: IP ${record.remoteIP} not allowed for domain ${domainConfig.domains.join( + ', ' + )}` + ); + } + } else if ( + this.settings.defaultAllowedIPs && + this.settings.defaultAllowedIPs.length > 0 + ) { + if ( + !this.securityManager.isIPAuthorized( + record.remoteIP, + this.settings.defaultAllowedIPs, + this.settings.defaultBlockedIPs || [] + ) + ) { + return rejectIncomingConnection( + 'rejected', + `Connection rejected: IP ${record.remoteIP} not allowed by default allowed list` + ); + } + } + + // Save the initial SNI + if (serverName) { + record.lockedDomain = serverName; + } + + // Set up the direct connection + this.setupDirectConnection( + socket, + record, + domainConfig, + serverName, + initialChunk, + overridePort + ); + }; + + // --- PORT RANGE-BASED HANDLING --- + // Only apply port-based rules if the incoming port is within one of the global port ranges. + if (this.portRangeManager.isPortInGlobalRanges(localPort)) { + if (this.portRangeManager.shouldUseGlobalForwarding(localPort)) { + if ( + this.settings.defaultAllowedIPs && + this.settings.defaultAllowedIPs.length > 0 && + !this.securityManager.isIPAuthorized(record.remoteIP, this.settings.defaultAllowedIPs) + ) { + console.log( + `[${connectionId}] Connection from ${record.remoteIP} rejected: IP ${record.remoteIP} not allowed in global default allowed list.` + ); + socket.end(); + return; + } + if (this.settings.enableDetailedLogging) { + console.log( + `[${connectionId}] Port-based connection from ${record.remoteIP} on port ${localPort} forwarded to global target IP ${this.settings.targetIP}.` + ); + } + setupConnection( + '', + undefined, + { + domains: ['global'], + allowedIPs: this.settings.defaultAllowedIPs || [], + blockedIPs: this.settings.defaultBlockedIPs || [], + targetIPs: [this.settings.targetIP!], + portRanges: [], + }, + localPort + ); + return; + } else { + // Attempt to find a matching forced domain config based on the local port. + const forcedDomain = this.domainConfigManager.findDomainConfigForPort(localPort); + + if (forcedDomain) { + const ipRules = this.domainConfigManager.getEffectiveIPRules(forcedDomain); + + if (!this.securityManager.isIPAuthorized(record.remoteIP, ipRules.allowedIPs, ipRules.blockedIPs)) { + console.log( + `[${connectionId}] Connection from ${record.remoteIP} rejected: IP not allowed for domain ${forcedDomain.domains.join( + ', ' + )} on port ${localPort}.` + ); + socket.end(); + return; + } + + if (this.settings.enableDetailedLogging) { + console.log( + `[${connectionId}] Port-based connection from ${record.remoteIP} on port ${localPort} matched domain ${forcedDomain.domains.join( + ', ' + )}.` + ); + } + + 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) { + initialDataReceived = false; + + socket.once('data', (chunk: Buffer) => { + // Clear timeout immediately + if (initialTimeout) { + clearTimeout(initialTimeout); + initialTimeout = null; + } + + initialDataReceived = true; + + // Block non-TLS connections on port 443 + if (!this.tlsManager.isTlsHandshake(chunk) && localPort === 443) { + console.log( + `[${connectionId}] Non-TLS connection detected on port 443 in SNI handler. ` + + `Terminating connection - only TLS traffic is allowed on standard HTTPS port.` + ); + if (record.incomingTerminationReason === null) { + record.incomingTerminationReason = 'non_tls_blocked'; + this.connectionManager.incrementTerminationStat('incoming', 'non_tls_blocked'); + } + socket.end(); + this.connectionManager.cleanupConnection(record, 'non_tls_blocked'); + return; + } + + // Try to extract SNI + let serverName = ''; + + if (this.tlsManager.isTlsHandshake(chunk)) { + record.isTLS = true; + + if (this.settings.enableTlsDebugLogging) { + console.log( + `[${connectionId}] Extracting SNI from TLS handshake, ${chunk.length} bytes` + ); + } + + // Create connection info object for SNI extraction + const connInfo = { + sourceIp: record.remoteIP, + sourcePort: socket.remotePort || 0, + destIp: socket.localAddress || '', + destPort: socket.localPort || 0, + }; + + // Extract SNI + serverName = this.tlsManager.extractSNI(chunk, connInfo) || ''; + } + + // Lock the connection to the negotiated SNI. + record.lockedDomain = serverName; + + if (this.settings.enableDetailedLogging) { + console.log( + `[${connectionId}] Received connection from ${record.remoteIP} with SNI: ${ + serverName || '(empty)' + }` + ); + } + + setupConnection(serverName, chunk); + }); + } else { + initialDataReceived = true; + record.hasReceivedInitialData = true; + + if ( + this.settings.defaultAllowedIPs && + this.settings.defaultAllowedIPs.length > 0 && + !this.securityManager.isIPAuthorized(record.remoteIP, this.settings.defaultAllowedIPs) + ) { + return rejectIncomingConnection( + 'rejected', + `Connection rejected: IP ${record.remoteIP} not allowed for non-SNI connection` + ); + } + + setupConnection(''); + } + } + + /** + * Sets up a direct connection to the target + */ + private setupDirectConnection( + socket: plugins.net.Socket, + record: IConnectionRecord, + domainConfig?: IDomainConfig, + serverName?: string, + initialChunk?: Buffer, + overridePort?: number + ): void { + const connectionId = record.id; + + // Determine target host + const targetHost = domainConfig + ? this.domainConfigManager.getTargetIP(domainConfig) + : this.settings.targetIP!; + + // Determine target port + const targetPort = overridePort !== undefined + ? overridePort + : this.settings.toPort; + + // Setup connection options + const connectionOptions: plugins.net.NetConnectOpts = { + host: targetHost, + port: targetPort, + }; + + // Preserve source IP if configured + if (this.settings.preserveSourceIP) { + connectionOptions.localAddress = record.remoteIP.replace('::ffff:', ''); + } + + // Create a safe queue for incoming data + const dataQueue: Buffer[] = []; + let queueSize = 0; + let processingQueue = false; + let drainPending = false; + let pipingEstablished = false; + + // Pause the incoming socket to prevent buffer overflows + socket.pause(); + + // Function to safely process the data queue without losing events + const processDataQueue = () => { + if (processingQueue || dataQueue.length === 0 || pipingEstablished) return; + + processingQueue = true; + + try { + // Process all queued chunks with the current active handler + while (dataQueue.length > 0) { + const chunk = dataQueue.shift()!; + queueSize -= chunk.length; + + // Once piping is established, we shouldn't get here, + // but just in case, pass to the outgoing socket directly + if (pipingEstablished && record.outgoing) { + record.outgoing.write(chunk); + continue; + } + + // Track bytes received + record.bytesReceived += chunk.length; + + // Check for TLS handshake + if (!record.isTLS && this.tlsManager.isTlsHandshake(chunk)) { + record.isTLS = true; + + if (this.settings.enableTlsDebugLogging) { + console.log( + `[${connectionId}] TLS handshake detected in tempDataHandler, ${chunk.length} bytes` + ); + } + } + + // Check if adding this chunk would exceed the buffer limit + const newSize = record.pendingDataSize + chunk.length; + + if (this.settings.maxPendingDataSize && newSize > this.settings.maxPendingDataSize) { + console.log( + `[${connectionId}] Buffer limit exceeded for connection from ${record.remoteIP}: ${newSize} bytes > ${this.settings.maxPendingDataSize} bytes` + ); + socket.end(); // Gracefully close the socket + this.connectionManager.initiateCleanupOnce(record, 'buffer_limit_exceeded'); + return; + } + + // Buffer the chunk and update the size counter + record.pendingData.push(Buffer.from(chunk)); + record.pendingDataSize = newSize; + this.timeoutManager.updateActivity(record); + } + } finally { + processingQueue = false; + + // If there's a pending drain and we've processed everything, + // signal we're ready for more data if we haven't established piping yet + if (drainPending && dataQueue.length === 0 && !pipingEstablished) { + drainPending = false; + socket.resume(); + } + } + }; + + // Unified data handler that safely queues incoming data + const safeDataHandler = (chunk: Buffer) => { + // If piping is already established, just let the pipe handle it + if (pipingEstablished) return; + + // Add to our queue for orderly processing + dataQueue.push(Buffer.from(chunk)); // Make a copy to be safe + queueSize += chunk.length; + + // If queue is getting large, pause socket until we catch up + if (this.settings.maxPendingDataSize && queueSize > this.settings.maxPendingDataSize * 0.8) { + socket.pause(); + drainPending = true; + } + + // Process the queue + processDataQueue(); + }; + + // Add our safe data handler + socket.on('data', safeDataHandler); + + // Add initial chunk to pending data if present + if (initialChunk) { + record.bytesReceived += initialChunk.length; + record.pendingData.push(Buffer.from(initialChunk)); + record.pendingDataSize = initialChunk.length; + } + + // Create the target socket but don't set up piping immediately + const targetSocket = plugins.net.connect(connectionOptions); + record.outgoing = targetSocket; + record.outgoingStartTime = Date.now(); + + // Apply socket optimizations + targetSocket.setNoDelay(this.settings.noDelay); + + // Apply keep-alive settings to the outgoing connection as well + if (this.settings.keepAlive) { + targetSocket.setKeepAlive(true, this.settings.keepAliveInitialDelay); + + // Apply enhanced TCP keep-alive options if enabled + if (this.settings.enableKeepAliveProbes) { + try { + if ('setKeepAliveProbes' in targetSocket) { + (targetSocket as any).setKeepAliveProbes(10); + } + if ('setKeepAliveInterval' in targetSocket) { + (targetSocket as any).setKeepAliveInterval(1000); + } + } catch (err) { + // Ignore errors - these are optional enhancements + if (this.settings.enableDetailedLogging) { + console.log( + `[${connectionId}] Enhanced TCP keep-alive not supported for outgoing socket: ${err}` + ); + } + } + } + } + + // Setup specific error handler for connection phase + targetSocket.once('error', (err) => { + // This handler runs only once during the initial connection phase + const code = (err as any).code; + console.log( + `[${connectionId}] Connection setup error to ${targetHost}:${connectionOptions.port}: ${err.message} (${code})` + ); + + // Resume the incoming socket to prevent it from hanging + socket.resume(); + + if (code === 'ECONNREFUSED') { + console.log( + `[${connectionId}] Target ${targetHost}:${connectionOptions.port} refused connection` + ); + } else if (code === 'ETIMEDOUT') { + console.log( + `[${connectionId}] Connection to ${targetHost}:${connectionOptions.port} timed out` + ); + } else if (code === 'ECONNRESET') { + console.log( + `[${connectionId}] Connection to ${targetHost}:${connectionOptions.port} was reset` + ); + } else if (code === 'EHOSTUNREACH') { + console.log(`[${connectionId}] Host ${targetHost} is unreachable`); + } + + // Clear any existing error handler after connection phase + targetSocket.removeAllListeners('error'); + + // Re-add the normal error handler for established connections + targetSocket.on('error', this.connectionManager.handleError('outgoing', record)); + + if (record.outgoingTerminationReason === null) { + record.outgoingTerminationReason = 'connection_failed'; + this.connectionManager.incrementTerminationStat('outgoing', 'connection_failed'); + } + + // Clean up the connection + this.connectionManager.initiateCleanupOnce(record, `connection_failed_${code}`); + }); + + // Setup close handler + targetSocket.on('close', this.connectionManager.handleClose('outgoing', record)); + socket.on('close', this.connectionManager.handleClose('incoming', record)); + + // Handle timeouts with keep-alive awareness + socket.on('timeout', () => { + // For keep-alive connections, just log a warning instead of closing + if (record.hasKeepAlive) { + console.log( + `[${connectionId}] Timeout event on incoming keep-alive connection from ${ + record.remoteIP + } after ${plugins.prettyMs( + this.settings.socketTimeout || 3600000 + )}. Connection preserved.` + ); + return; + } + + // For non-keep-alive connections, proceed with normal cleanup + console.log( + `[${connectionId}] Timeout on incoming side from ${ + record.remoteIP + } after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}` + ); + if (record.incomingTerminationReason === null) { + record.incomingTerminationReason = 'timeout'; + this.connectionManager.incrementTerminationStat('incoming', 'timeout'); + } + this.connectionManager.initiateCleanupOnce(record, 'timeout_incoming'); + }); + + targetSocket.on('timeout', () => { + // For keep-alive connections, just log a warning instead of closing + if (record.hasKeepAlive) { + console.log( + `[${connectionId}] Timeout event on outgoing keep-alive connection from ${ + record.remoteIP + } after ${plugins.prettyMs( + this.settings.socketTimeout || 3600000 + )}. Connection preserved.` + ); + return; + } + + // For non-keep-alive connections, proceed with normal cleanup + console.log( + `[${connectionId}] Timeout on outgoing side from ${ + record.remoteIP + } after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}` + ); + if (record.outgoingTerminationReason === null) { + record.outgoingTerminationReason = 'timeout'; + this.connectionManager.incrementTerminationStat('outgoing', 'timeout'); + } + this.connectionManager.initiateCleanupOnce(record, 'timeout_outgoing'); + }); + + // Apply socket timeouts + this.timeoutManager.applySocketTimeouts(record); + + // Track outgoing data for bytes counting + targetSocket.on('data', (chunk: Buffer) => { + record.bytesSent += chunk.length; + this.timeoutManager.updateActivity(record); + }); + + // Wait for the outgoing connection to be ready before setting up piping + targetSocket.once('connect', () => { + // Clear the initial connection error handler + targetSocket.removeAllListeners('error'); + + // Add the normal error handler for established connections + targetSocket.on('error', this.connectionManager.handleError('outgoing', record)); + + // Process any remaining data in the queue before switching to piping + processDataQueue(); + + // Set up piping immediately + pipingEstablished = true; + + // Flush all pending data to target + if (record.pendingData.length > 0) { + const combinedData = Buffer.concat(record.pendingData); + + if (this.settings.enableDetailedLogging) { + console.log(`[${connectionId}] Forwarding ${combinedData.length} bytes of initial data to target`); + } + + // Write pending data immediately + targetSocket.write(combinedData, (err) => { + if (err) { + console.log(`[${connectionId}] Error writing pending data to target: ${err.message}`); + return this.connectionManager.initiateCleanupOnce(record, 'write_error'); + } + }); + + // Clear the buffer now that we've processed it + record.pendingData = []; + record.pendingDataSize = 0; + } + + // Setup piping in both directions without any delays + socket.pipe(targetSocket); + targetSocket.pipe(socket); + + // Resume the socket to ensure data flows + socket.resume(); + + // Process any data that might be queued in the interim + if (dataQueue.length > 0) { + // Write any remaining queued data directly to the target socket + for (const chunk of dataQueue) { + targetSocket.write(chunk); + } + // Clear the queue + dataQueue.length = 0; + queueSize = 0; + } + + if (this.settings.enableDetailedLogging) { + console.log( + `[${connectionId}] Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` + + `${ + serverName + ? ` (SNI: ${serverName})` + : domainConfig + ? ` (Port-based for domain: ${domainConfig.domains.join(', ')})` + : '' + }` + + ` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${ + record.hasKeepAlive ? 'Yes' : 'No' + }` + ); + } else { + console.log( + `Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` + + `${ + serverName + ? ` (SNI: ${serverName})` + : domainConfig + ? ` (Port-based for domain: ${domainConfig.domains.join(', ')})` + : '' + }` + ); + } + + // Add the renegotiation handler for SNI validation + if (serverName) { + // Create connection info object for the existing connection + const connInfo = { + sourceIp: record.remoteIP, + sourcePort: record.incoming.remotePort || 0, + destIp: record.incoming.localAddress || '', + destPort: record.incoming.localPort || 0, + }; + + // Create a renegotiation handler function + const renegotiationHandler = this.tlsManager.createRenegotiationHandler( + connectionId, + serverName, + connInfo, + (connectionId, reason) => this.connectionManager.initiateCleanupOnce(record, reason) + ); + + // Store the handler in the connection record so we can remove it during cleanup + record.renegotiationHandler = renegotiationHandler; + + // Add the handler to the socket + socket.on('data', renegotiationHandler); + + if (this.settings.enableDetailedLogging) { + console.log( + `[${connectionId}] TLS renegotiation handler installed for SNI domain: ${serverName}` + ); + if (this.settings.allowSessionTicket === false) { + console.log( + `[${connectionId}] Session ticket usage is disabled. Connection will be reset on reconnection attempts.` + ); + } + } + } + + // Set connection timeout + record.cleanupTimer = this.timeoutManager.setupConnectionTimeout( + record, + (record, reason) => { + console.log( + `[${connectionId}] Connection from ${record.remoteIP} exceeded max lifetime, forcing cleanup.` + ); + this.connectionManager.initiateCleanupOnce(record, reason); + } + ); + + // Mark TLS handshake as complete for TLS connections + if (record.isTLS) { + record.tlsHandshakeComplete = true; + + if (this.settings.enableTlsDebugLogging) { + console.log( + `[${connectionId}] TLS handshake complete for connection from ${record.remoteIP}` + ); + } + } + }); + } +} \ No newline at end of file diff --git a/ts/classes.pp.connectionmanager.ts b/ts/classes.pp.connectionmanager.ts new file mode 100644 index 0000000..0cc7ec1 --- /dev/null +++ b/ts/classes.pp.connectionmanager.ts @@ -0,0 +1,446 @@ +import * as plugins from './plugins.js'; +import type { IConnectionRecord, IPortProxySettings } from './classes.pp.interfaces.js'; +import { SecurityManager } from './classes.pp.securitymanager.js'; +import { TimeoutManager } from './classes.pp.timeoutmanager.js'; + +/** + * Manages connection lifecycle, tracking, and cleanup + */ +export class ConnectionManager { + private connectionRecords: Map = new Map(); + private terminationStats: { + incoming: Record; + outgoing: Record; + } = { incoming: {}, outgoing: {} }; + + constructor( + private settings: IPortProxySettings, + private securityManager: SecurityManager, + private timeoutManager: TimeoutManager + ) {} + + /** + * Generate a unique connection ID + */ + public generateConnectionId(): string { + return Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + } + + /** + * Create and track a new connection + */ + public createConnection(socket: plugins.net.Socket): IConnectionRecord { + const connectionId = this.generateConnectionId(); + const remoteIP = socket.remoteAddress || ''; + const localPort = socket.localPort || 0; + + const record: IConnectionRecord = { + id: connectionId, + incoming: socket, + outgoing: null, + incomingStartTime: Date.now(), + lastActivity: Date.now(), + connectionClosed: false, + pendingData: [], + pendingDataSize: 0, + bytesReceived: 0, + bytesSent: 0, + remoteIP, + localPort, + isTLS: false, + tlsHandshakeComplete: false, + hasReceivedInitialData: false, + hasKeepAlive: false, + incomingTerminationReason: null, + outgoingTerminationReason: null, + usingNetworkProxy: false, + isBrowserConnection: false, + domainSwitches: 0 + }; + + this.trackConnection(connectionId, record); + return record; + } + + /** + * Track an existing connection + */ + public trackConnection(connectionId: string, record: IConnectionRecord): void { + this.connectionRecords.set(connectionId, record); + this.securityManager.trackConnectionByIP(record.remoteIP, connectionId); + } + + /** + * Get a connection by ID + */ + public getConnection(connectionId: string): IConnectionRecord | undefined { + return this.connectionRecords.get(connectionId); + } + + /** + * Get all active connections + */ + public getConnections(): Map { + return this.connectionRecords; + } + + /** + * Get count of active connections + */ + public getConnectionCount(): number { + return this.connectionRecords.size; + } + + /** + * Initiates cleanup once for a connection + */ + public initiateCleanupOnce(record: IConnectionRecord, reason: string = 'normal'): void { + if (this.settings.enableDetailedLogging) { + console.log(`[${record.id}] Connection cleanup initiated for ${record.remoteIP} (${reason})`); + } + + if ( + record.incomingTerminationReason === null || + record.incomingTerminationReason === undefined + ) { + record.incomingTerminationReason = reason; + this.incrementTerminationStat('incoming', reason); + } + + this.cleanupConnection(record, reason); + } + + /** + * Clean up a connection record + */ + public cleanupConnection(record: IConnectionRecord, reason: string = 'normal'): void { + if (!record.connectionClosed) { + record.connectionClosed = true; + + // Track connection termination + this.securityManager.removeConnectionByIP(record.remoteIP, record.id); + + if (record.cleanupTimer) { + clearTimeout(record.cleanupTimer); + record.cleanupTimer = undefined; + } + + // Detailed logging data + const duration = Date.now() - record.incomingStartTime; + const bytesReceived = record.bytesReceived; + const bytesSent = record.bytesSent; + + // Remove all data handlers to make sure we clean up properly + if (record.incoming) { + try { + // Remove our safe data handler + record.incoming.removeAllListeners('data'); + // Reset the handler references + record.renegotiationHandler = undefined; + } catch (err) { + console.log(`[${record.id}] Error removing data handlers: ${err}`); + } + } + + // Handle incoming socket + this.cleanupSocket(record, 'incoming', record.incoming); + + // Handle outgoing socket + if (record.outgoing) { + this.cleanupSocket(record, 'outgoing', record.outgoing); + } + + // Clear pendingData to avoid memory leaks + record.pendingData = []; + record.pendingDataSize = 0; + + // Remove the record from the tracking map + this.connectionRecords.delete(record.id); + + // Log connection details + if (this.settings.enableDetailedLogging) { + console.log( + `[${record.id}] Connection from ${record.remoteIP} on port ${record.localPort} terminated (${reason}).` + + ` Duration: ${plugins.prettyMs(duration)}, Bytes IN: ${bytesReceived}, OUT: ${bytesSent}, ` + + `TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${record.hasKeepAlive ? 'Yes' : 'No'}` + + `${record.usingNetworkProxy ? ', Using NetworkProxy' : ''}` + + `${record.domainSwitches ? `, Domain switches: ${record.domainSwitches}` : ''}` + ); + } else { + console.log( + `[${record.id}] Connection from ${record.remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}` + ); + } + } + } + + /** + * Helper method to clean up a socket + */ + private cleanupSocket(record: IConnectionRecord, side: 'incoming' | 'outgoing', socket: plugins.net.Socket): void { + try { + if (!socket.destroyed) { + // Try graceful shutdown first, then force destroy after a short timeout + socket.end(); + const socketTimeout = setTimeout(() => { + try { + if (!socket.destroyed) { + socket.destroy(); + } + } catch (err) { + console.log(`[${record.id}] Error destroying ${side} socket: ${err}`); + } + }, 1000); + + // Ensure the timeout doesn't block Node from exiting + if (socketTimeout.unref) { + socketTimeout.unref(); + } + } + } catch (err) { + console.log(`[${record.id}] Error closing ${side} socket: ${err}`); + try { + if (!socket.destroyed) { + socket.destroy(); + } + } catch (destroyErr) { + console.log(`[${record.id}] Error destroying ${side} socket: ${destroyErr}`); + } + } + } + + /** + * Creates a generic error handler for incoming or outgoing sockets + */ + public handleError(side: 'incoming' | 'outgoing', record: IConnectionRecord) { + return (err: Error) => { + const code = (err as any).code; + let reason = 'error'; + + const now = Date.now(); + const connectionDuration = now - record.incomingStartTime; + const lastActivityAge = now - record.lastActivity; + + if (code === 'ECONNRESET') { + reason = 'econnreset'; + console.log( + `[${record.id}] ECONNRESET on ${side} side from ${record.remoteIP}: ${err.message}. ` + + `Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)} ago` + ); + } else if (code === 'ETIMEDOUT') { + reason = 'etimedout'; + console.log( + `[${record.id}] ETIMEDOUT on ${side} side from ${record.remoteIP}: ${err.message}. ` + + `Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)} ago` + ); + } else { + console.log( + `[${record.id}] Error on ${side} side from ${record.remoteIP}: ${err.message}. ` + + `Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)} ago` + ); + } + + if (side === 'incoming' && record.incomingTerminationReason === null) { + record.incomingTerminationReason = reason; + this.incrementTerminationStat('incoming', reason); + } else if (side === 'outgoing' && record.outgoingTerminationReason === null) { + record.outgoingTerminationReason = reason; + this.incrementTerminationStat('outgoing', reason); + } + + this.initiateCleanupOnce(record, reason); + }; + } + + /** + * Creates a generic close handler for incoming or outgoing sockets + */ + public handleClose(side: 'incoming' | 'outgoing', record: IConnectionRecord) { + return () => { + if (this.settings.enableDetailedLogging) { + console.log(`[${record.id}] Connection closed on ${side} side from ${record.remoteIP}`); + } + + if (side === 'incoming' && record.incomingTerminationReason === null) { + record.incomingTerminationReason = 'normal'; + this.incrementTerminationStat('incoming', 'normal'); + } else if (side === 'outgoing' && record.outgoingTerminationReason === null) { + record.outgoingTerminationReason = 'normal'; + this.incrementTerminationStat('outgoing', 'normal'); + // Record the time when outgoing socket closed. + record.outgoingClosedTime = Date.now(); + } + + this.initiateCleanupOnce(record, 'closed_' + side); + }; + } + + /** + * Increment termination statistics + */ + public incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void { + this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1; + } + + /** + * Get termination statistics + */ + public getTerminationStats(): { incoming: Record; outgoing: Record } { + return this.terminationStats; + } + + /** + * Check for stalled/inactive connections + */ + public performInactivityCheck(): void { + const now = Date.now(); + const connectionIds = [...this.connectionRecords.keys()]; + + for (const id of connectionIds) { + const record = this.connectionRecords.get(id); + if (!record) continue; + + // Skip inactivity check if disabled or for immortal keep-alive connections + if ( + this.settings.disableInactivityCheck || + (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') + ) { + continue; + } + + const inactivityTime = now - record.lastActivity; + + // Use extended timeout for extended-treatment keep-alive connections + let effectiveTimeout = this.settings.inactivityTimeout!; + if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') { + const multiplier = this.settings.keepAliveInactivityMultiplier || 6; + effectiveTimeout = effectiveTimeout * multiplier; + } + + if (inactivityTime > effectiveTimeout && !record.connectionClosed) { + // For keep-alive connections, issue a warning first + if (record.hasKeepAlive && !record.inactivityWarningIssued) { + console.log( + `[${id}] Warning: Keep-alive connection from ${record.remoteIP} inactive for ${ + plugins.prettyMs(inactivityTime) + }. Will close in 10 minutes if no activity.` + ); + + // Set warning flag and add grace period + record.inactivityWarningIssued = true; + record.lastActivity = now - (effectiveTimeout - 600000); + + // Try to stimulate activity with a probe packet + if (record.outgoing && !record.outgoing.destroyed) { + try { + record.outgoing.write(Buffer.alloc(0)); + + if (this.settings.enableDetailedLogging) { + console.log(`[${id}] Sent probe packet to test keep-alive connection`); + } + } catch (err) { + console.log(`[${id}] Error sending probe packet: ${err}`); + } + } + } else { + // For non-keep-alive or after warning, close the connection + console.log( + `[${id}] Inactivity check: No activity on connection from ${record.remoteIP} ` + + `for ${plugins.prettyMs(inactivityTime)}.` + + (record.hasKeepAlive ? ' Despite keep-alive being enabled.' : '') + ); + this.cleanupConnection(record, 'inactivity'); + } + } else if (inactivityTime <= effectiveTimeout && record.inactivityWarningIssued) { + // If activity detected after warning, clear the warning + if (this.settings.enableDetailedLogging) { + console.log( + `[${id}] Connection activity detected after inactivity warning, resetting warning` + ); + } + record.inactivityWarningIssued = false; + } + + // Parity check: if outgoing socket closed and incoming remains active + if ( + record.outgoingClosedTime && + !record.incoming.destroyed && + !record.connectionClosed && + now - record.outgoingClosedTime > 120000 + ) { + console.log( + `[${id}] Parity check: Incoming socket for ${record.remoteIP} still active ${ + plugins.prettyMs(now - record.outgoingClosedTime) + } after outgoing closed.` + ); + this.cleanupConnection(record, 'parity_check'); + } + } + } + + /** + * Clear all connections (for shutdown) + */ + public clearConnections(): void { + // Create a copy of the keys to avoid modification during iteration + const connectionIds = [...this.connectionRecords.keys()]; + + // First pass: End all connections gracefully + for (const id of connectionIds) { + const record = this.connectionRecords.get(id); + if (record) { + try { + // Clear any timers + if (record.cleanupTimer) { + clearTimeout(record.cleanupTimer); + record.cleanupTimer = undefined; + } + + // End sockets gracefully + if (record.incoming && !record.incoming.destroyed) { + record.incoming.end(); + } + + if (record.outgoing && !record.outgoing.destroyed) { + record.outgoing.end(); + } + } catch (err) { + console.log(`Error during graceful connection end for ${id}: ${err}`); + } + } + } + + // Short delay to allow graceful ends to process + setTimeout(() => { + // Second pass: Force destroy everything + for (const id of connectionIds) { + const record = this.connectionRecords.get(id); + if (record) { + try { + // Remove all listeners to prevent memory leaks + if (record.incoming) { + record.incoming.removeAllListeners(); + if (!record.incoming.destroyed) { + record.incoming.destroy(); + } + } + + if (record.outgoing) { + record.outgoing.removeAllListeners(); + if (!record.outgoing.destroyed) { + record.outgoing.destroy(); + } + } + } catch (err) { + console.log(`Error during forced connection destruction for ${id}: ${err}`); + } + } + } + + // Clear all maps + this.connectionRecords.clear(); + this.terminationStats = { incoming: {}, outgoing: {} }; + }, 100); + } +} \ No newline at end of file diff --git a/ts/classes.pp.domainconfigmanager.ts b/ts/classes.pp.domainconfigmanager.ts new file mode 100644 index 0000000..fb386d0 --- /dev/null +++ b/ts/classes.pp.domainconfigmanager.ts @@ -0,0 +1,123 @@ +import * as plugins from './plugins.js'; +import type { IDomainConfig, IPortProxySettings } from './classes.pp.interfaces.js'; + +/** + * Manages domain configurations and target selection + */ +export class DomainConfigManager { + // Track round-robin indices for domain configs + private domainTargetIndices: Map = new Map(); + + constructor(private settings: IPortProxySettings) {} + + /** + * Updates the domain configurations + */ + public updateDomainConfigs(newDomainConfigs: IDomainConfig[]): void { + this.settings.domainConfigs = newDomainConfigs; + + // Reset target indices for removed configs + const currentConfigSet = new Set(newDomainConfigs); + for (const [config] of this.domainTargetIndices) { + if (!currentConfigSet.has(config)) { + this.domainTargetIndices.delete(config); + } + } + } + + /** + * Get all domain configurations + */ + public getDomainConfigs(): IDomainConfig[] { + return this.settings.domainConfigs; + } + + /** + * Find domain config matching a server name + */ + public findDomainConfig(serverName: string): IDomainConfig | undefined { + if (!serverName) return undefined; + + return this.settings.domainConfigs.find((config) => + config.domains.some((d) => plugins.minimatch(serverName, d)) + ); + } + + /** + * Find domain config for a specific port + */ + public findDomainConfigForPort(port: number): IDomainConfig | undefined { + return this.settings.domainConfigs.find( + (domain) => + domain.portRanges && + domain.portRanges.length > 0 && + this.isPortInRanges(port, domain.portRanges) + ); + } + + /** + * Check if a port is within any of the given ranges + */ + public isPortInRanges(port: number, ranges: Array<{ from: number; to: number }>): boolean { + return ranges.some((range) => port >= range.from && port <= range.to); + } + + /** + * Get target IP with round-robin support + */ + public getTargetIP(domainConfig: IDomainConfig): string { + if (domainConfig.targetIPs && domainConfig.targetIPs.length > 0) { + const currentIndex = this.domainTargetIndices.get(domainConfig) || 0; + const ip = domainConfig.targetIPs[currentIndex % domainConfig.targetIPs.length]; + this.domainTargetIndices.set(domainConfig, currentIndex + 1); + return ip; + } + + return this.settings.targetIP || 'localhost'; + } + + /** + * Checks if a domain should use NetworkProxy + */ + public shouldUseNetworkProxy(domainConfig: IDomainConfig): boolean { + return !!domainConfig.useNetworkProxy; + } + + /** + * Gets the NetworkProxy port for a domain + */ + public getNetworkProxyPort(domainConfig: IDomainConfig): number | undefined { + return domainConfig.useNetworkProxy + ? (domainConfig.networkProxyPort || this.settings.networkProxyPort) + : undefined; + } + + /** + * Get effective allowed and blocked IPs for a domain + */ + public getEffectiveIPRules(domainConfig: IDomainConfig): { + allowedIPs: string[], + blockedIPs: string[] + } { + return { + allowedIPs: [ + ...domainConfig.allowedIPs, + ...(this.settings.defaultAllowedIPs || []) + ], + blockedIPs: [ + ...(domainConfig.blockedIPs || []), + ...(this.settings.defaultBlockedIPs || []) + ] + }; + } + + /** + * Get connection timeout for a domain + */ + public getConnectionTimeout(domainConfig?: IDomainConfig): number { + if (domainConfig?.connectionTimeout) { + return domainConfig.connectionTimeout; + } + return this.settings.maxConnectionLifetime || 86400000; // 24 hours default + } +} \ No newline at end of file diff --git a/ts/classes.pp.interfaces.ts b/ts/classes.pp.interfaces.ts new file mode 100644 index 0000000..f096cf7 --- /dev/null +++ b/ts/classes.pp.interfaces.ts @@ -0,0 +1,136 @@ +import * as plugins from './plugins.js'; + +/** Domain configuration with per-domain allowed port ranges */ +export interface IDomainConfig { + domains: string[]; // Glob patterns for domain(s) + allowedIPs: string[]; // Glob patterns for allowed IPs + blockedIPs?: string[]; // Glob patterns for blocked IPs + targetIPs?: string[]; // If multiple targetIPs are given, use round robin. + portRanges?: Array<{ from: number; to: number }>; // Optional port ranges + // Allow domain-specific timeout override + connectionTimeout?: number; // Connection timeout override (ms) + + // NetworkProxy integration options for this specific domain + useNetworkProxy?: boolean; // Whether to use NetworkProxy for this domain + networkProxyPort?: number; // Override default NetworkProxy port for this domain +} + +/** Port proxy settings including global allowed port ranges */ +export interface IPortProxySettings { + fromPort: number; + toPort: number; + targetIP?: string; // Global target host to proxy to, defaults to 'localhost' + domainConfigs: IDomainConfig[]; + sniEnabled?: boolean; + defaultAllowedIPs?: string[]; + defaultBlockedIPs?: string[]; + preserveSourceIP?: boolean; + + // TLS options + pfx?: Buffer; + key?: string | Buffer | Array; + passphrase?: string; + cert?: string | Buffer | Array; + ca?: string | Buffer | Array; + ciphers?: string; + honorCipherOrder?: boolean; + rejectUnauthorized?: boolean; + secureProtocol?: string; + servername?: string; + minVersion?: string; + maxVersion?: string; + + // Timeout settings + initialDataTimeout?: number; // Timeout for initial data/SNI (ms), default: 60000 (60s) + socketTimeout?: number; // Socket inactivity timeout (ms), default: 3600000 (1h) + inactivityCheckInterval?: number; // How often to check for inactive connections (ms), default: 60000 (60s) + maxConnectionLifetime?: number; // Default max connection lifetime (ms), default: 86400000 (24h) + inactivityTimeout?: number; // Inactivity timeout (ms), default: 14400000 (4h) + + gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown + 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 + + // Socket optimization settings + noDelay?: boolean; // Disable Nagle's algorithm (default: true) + keepAlive?: boolean; // Enable TCP keepalive (default: true) + keepAliveInitialDelay?: number; // Initial delay before sending keepalive probes (ms) + maxPendingDataSize?: number; // Maximum bytes to buffer during connection setup + + // Enhanced features + disableInactivityCheck?: boolean; // Disable inactivity checking entirely + enableKeepAliveProbes?: boolean; // Enable TCP keep-alive probes + enableDetailedLogging?: boolean; // Enable detailed connection logging + enableTlsDebugLogging?: boolean; // Enable TLS handshake debug logging + enableRandomizedTimeouts?: boolean; // Randomize timeouts slightly to prevent thundering herd + allowSessionTicket?: boolean; // Allow TLS session ticket for reconnection (default: true) + + // Rate limiting and security + maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP + connectionRateLimitPerMinute?: number; // Max new connections per minute from a single IP + + // Enhanced keep-alive settings + keepAliveTreatment?: 'standard' | 'extended' | 'immortal'; // How to treat keep-alive connections + keepAliveInactivityMultiplier?: number; // Multiplier for inactivity timeout for keep-alive connections + extendedKeepAliveLifetime?: number; // Extended lifetime for keep-alive connections (ms) + + // NetworkProxy integration + useNetworkProxy?: number[]; // Array of ports to forward to NetworkProxy + networkProxyPort?: number; // Port where NetworkProxy is listening (default: 8443) + + // ACME certificate management options + acme?: { + enabled?: boolean; // Whether to enable automatic certificate management + port?: number; // Port to listen on for ACME challenges (default: 80) + contactEmail?: string; // Email for Let's Encrypt account + useProduction?: boolean; // Whether to use Let's Encrypt production (default: false for staging) + renewThresholdDays?: number; // Days before expiry to renew certificates (default: 30) + autoRenew?: boolean; // Whether to automatically renew certificates (default: true) + certificateStore?: string; // Directory to store certificates (default: ./certs) + skipConfiguredCerts?: boolean; // Skip domains that already have certificates configured + }; +} + +/** + * Enhanced connection record + */ +export interface IConnectionRecord { + id: string; // Unique connection identifier + incoming: plugins.net.Socket; + outgoing: plugins.net.Socket | null; + incomingStartTime: number; + outgoingStartTime?: number; + outgoingClosedTime?: number; + lockedDomain?: string; // Used to lock this connection to the initial SNI + connectionClosed: boolean; // Flag to prevent multiple cleanup attempts + cleanupTimer?: NodeJS.Timeout; // Timer for max lifetime/inactivity + lastActivity: number; // Last activity timestamp for inactivity detection + pendingData: Buffer[]; // Buffer to hold data during connection setup + pendingDataSize: number; // Track total size of pending data + + // Enhanced tracking fields + bytesReceived: number; // Total bytes received + bytesSent: number; // Total bytes sent + remoteIP: string; // Remote IP (cached for logging after socket close) + localPort: number; // Local port (cached for logging) + isTLS: boolean; // Whether this connection is a TLS connection + tlsHandshakeComplete: boolean; // Whether the TLS handshake is complete + hasReceivedInitialData: boolean; // Whether initial data has been received + domainConfig?: IDomainConfig; // Associated domain config for this connection + + // Keep-alive tracking + hasKeepAlive: boolean; // Whether keep-alive is enabled for this connection + inactivityWarningIssued?: boolean; // Whether an inactivity warning has been issued + incomingTerminationReason?: string | null; // Reason for incoming termination + outgoingTerminationReason?: string | null; // Reason for outgoing termination + + // NetworkProxy tracking + usingNetworkProxy?: boolean; // Whether this connection is using a NetworkProxy + + // Renegotiation handler + renegotiationHandler?: (chunk: Buffer) => void; // Handler for renegotiation detection + + // Browser connection tracking + isBrowserConnection?: boolean; // Whether this connection appears to be from a browser + domainSwitches?: number; // Number of times the domain has been switched on this connection +} \ No newline at end of file diff --git a/ts/classes.pp.networkproxybridge.ts b/ts/classes.pp.networkproxybridge.ts new file mode 100644 index 0000000..b7d31d0 --- /dev/null +++ b/ts/classes.pp.networkproxybridge.ts @@ -0,0 +1,258 @@ +import * as plugins from './plugins.js'; +import { NetworkProxy } from './classes.networkproxy.js'; +import type { IConnectionRecord, IPortProxySettings, IDomainConfig } from './classes.pp.interfaces.js'; + +/** + * Manages NetworkProxy integration for TLS termination + */ +export class NetworkProxyBridge { + private networkProxy: NetworkProxy | null = null; + + constructor(private settings: IPortProxySettings) {} + + /** + * Initialize NetworkProxy instance + */ + public async initialize(): Promise { + if (!this.networkProxy && this.settings.useNetworkProxy && this.settings.useNetworkProxy.length > 0) { + // Configure NetworkProxy options based on PortProxy settings + const networkProxyOptions: any = { + port: this.settings.networkProxyPort!, + portProxyIntegration: true, + logLevel: this.settings.enableDetailedLogging ? 'debug' : 'info', + }; + + // Add ACME settings if configured + if (this.settings.acme) { + networkProxyOptions.acme = { ...this.settings.acme }; + } + + this.networkProxy = new NetworkProxy(networkProxyOptions); + + console.log(`Initialized NetworkProxy on port ${this.settings.networkProxyPort}`); + + // Convert and apply domain configurations to NetworkProxy + await this.syncDomainConfigsToNetworkProxy(); + } + } + + /** + * Get the NetworkProxy instance + */ + public getNetworkProxy(): NetworkProxy | null { + return this.networkProxy; + } + + /** + * Get the NetworkProxy port + */ + public getNetworkProxyPort(): number { + return this.networkProxy ? this.networkProxy.getListeningPort() : this.settings.networkProxyPort || 8443; + } + + /** + * Start NetworkProxy + */ + public async start(): Promise { + if (this.networkProxy) { + await this.networkProxy.start(); + console.log(`NetworkProxy started on port ${this.settings.networkProxyPort}`); + + // Log ACME status + if (this.settings.acme?.enabled) { + console.log( + `ACME certificate management is enabled (${ + this.settings.acme.useProduction ? 'Production' : 'Staging' + } mode)` + ); + console.log(`ACME HTTP challenge server on port ${this.settings.acme.port}`); + + // Register domains for ACME certificates if enabled + if (this.networkProxy.options.acme?.enabled) { + console.log('Registering domains with ACME certificate manager...'); + // The NetworkProxy will handle this internally via registerDomainsWithAcmeManager() + } + } + } + } + + /** + * Stop NetworkProxy + */ + public async stop(): Promise { + if (this.networkProxy) { + try { + console.log('Stopping NetworkProxy...'); + await this.networkProxy.stop(); + console.log('NetworkProxy stopped successfully'); + + // Log ACME shutdown if it was enabled + if (this.settings.acme?.enabled) { + console.log('ACME certificate manager stopped'); + } + } catch (err) { + console.log(`Error stopping NetworkProxy: ${err}`); + } + } + } + + /** + * Forwards a TLS connection to a NetworkProxy for handling + */ + public forwardToNetworkProxy( + connectionId: string, + socket: plugins.net.Socket, + record: IConnectionRecord, + initialData: Buffer, + customProxyPort?: number, + onError?: (reason: string) => void + ): void { + // Ensure NetworkProxy is initialized + if (!this.networkProxy) { + console.log( + `[${connectionId}] NetworkProxy not initialized. Cannot forward connection.` + ); + if (onError) { + onError('network_proxy_not_initialized'); + } + return; + } + + // Use the custom port if provided, otherwise use the default NetworkProxy port + const proxyPort = customProxyPort || this.networkProxy.getListeningPort(); + const proxyHost = 'localhost'; // Assuming NetworkProxy runs locally + + if (this.settings.enableDetailedLogging) { + console.log( + `[${connectionId}] Forwarding TLS connection to NetworkProxy at ${proxyHost}:${proxyPort}` + ); + } + + // Create a connection to the NetworkProxy + const proxySocket = plugins.net.connect({ + host: proxyHost, + port: proxyPort, + }); + + // Store the outgoing socket in the record + record.outgoing = proxySocket; + record.outgoingStartTime = Date.now(); + record.usingNetworkProxy = true; + + // Set up error handlers + proxySocket.on('error', (err) => { + console.log(`[${connectionId}] Error connecting to NetworkProxy: ${err.message}`); + if (onError) { + onError('network_proxy_connect_error'); + } + }); + + // Handle connection to NetworkProxy + proxySocket.on('connect', () => { + if (this.settings.enableDetailedLogging) { + console.log(`[${connectionId}] Connected to NetworkProxy at ${proxyHost}:${proxyPort}`); + } + + // First send the initial data that contains the TLS ClientHello + proxySocket.write(initialData); + + // Now set up bidirectional piping between client and NetworkProxy + socket.pipe(proxySocket); + proxySocket.pipe(socket); + + // Update activity on data transfer (caller should handle this) + if (this.settings.enableDetailedLogging) { + console.log(`[${connectionId}] TLS connection successfully forwarded to NetworkProxy`); + } + }); + } + + /** + * Synchronizes domain configurations to NetworkProxy + */ + public async syncDomainConfigsToNetworkProxy(): Promise { + if (!this.networkProxy) { + console.log('Cannot sync configurations - NetworkProxy not initialized'); + return; + } + + try { + // Get SSL certificates from assets + // Import fs directly since it's not in plugins + const fs = await import('fs'); + + let certPair; + try { + certPair = { + key: fs.readFileSync('assets/certs/key.pem', 'utf8'), + cert: fs.readFileSync('assets/certs/cert.pem', 'utf8'), + }; + } catch (certError) { + console.log(`Warning: Could not read default certificates: ${certError}`); + console.log( + 'Using empty certificate placeholders - ACME will generate proper certificates if enabled' + ); + + // Use empty placeholders - NetworkProxy will use its internal defaults + // or ACME will generate proper ones if enabled + certPair = { + key: '', + cert: '', + }; + } + + // Convert domain configs to NetworkProxy configs + const proxyConfigs = this.networkProxy.convertPortProxyConfigs( + this.settings.domainConfigs, + certPair + ); + + // Log ACME-eligible domains if ACME is enabled + if (this.settings.acme?.enabled) { + const acmeEligibleDomains = proxyConfigs + .filter((config) => !config.hostName.includes('*')) // Exclude wildcards + .map((config) => config.hostName); + + if (acmeEligibleDomains.length > 0) { + console.log(`Domains eligible for ACME certificates: ${acmeEligibleDomains.join(', ')}`); + } else { + console.log('No domains eligible for ACME certificates found in configuration'); + } + } + + // Update NetworkProxy with the converted configs + await this.networkProxy.updateProxyConfigs(proxyConfigs); + console.log(`Successfully synchronized ${proxyConfigs.length} domain configurations to NetworkProxy`); + } catch (err) { + console.log(`Failed to sync configurations: ${err}`); + } + } + + /** + * Request a certificate for a specific domain + */ + public async requestCertificate(domain: string): Promise { + if (!this.networkProxy) { + console.log('Cannot request certificate - NetworkProxy not initialized'); + return false; + } + + if (!this.settings.acme?.enabled) { + console.log('Cannot request certificate - ACME is not enabled'); + return false; + } + + try { + const result = await this.networkProxy.requestCertificate(domain); + if (result) { + console.log(`Certificate request for ${domain} submitted successfully`); + } else { + console.log(`Certificate request for ${domain} failed`); + } + return result; + } catch (err) { + console.log(`Error requesting certificate: ${err}`); + return false; + } + } +} \ No newline at end of file diff --git a/ts/classes.pp.portproxy.ts b/ts/classes.pp.portproxy.ts new file mode 100644 index 0000000..20427de --- /dev/null +++ b/ts/classes.pp.portproxy.ts @@ -0,0 +1,344 @@ +import * as plugins from './plugins.js'; +import type { IPortProxySettings, IDomainConfig } from './classes.pp.interfaces.js'; +import { ConnectionManager } from './classes.pp.connectionmanager.js'; +import { SecurityManager } from './classes.pp.securitymanager.js'; +import { DomainConfigManager } from './classes.pp.domainconfigmanager.js'; +import { TlsManager } from './classes.pp.tlsmanager.js'; +import { NetworkProxyBridge } from './classes.pp.networkproxybridge.js'; +import { TimeoutManager } from './classes.pp.timeoutmanager.js'; +import { AcmeManager } from './classes.pp.acmemanager.js'; +import { PortRangeManager } from './classes.pp.portrangemanager.js'; +import { ConnectionHandler } from './classes.pp.connectionhandler.js'; + +/** + * PortProxy - Main class that coordinates all components + */ +export class PortProxy { + private netServers: plugins.net.Server[] = []; + private connectionLogger: NodeJS.Timeout | null = null; + private isShuttingDown: boolean = false; + + // Component managers + private connectionManager: ConnectionManager; + private securityManager: SecurityManager; + public domainConfigManager: DomainConfigManager; + private tlsManager: TlsManager; + private networkProxyBridge: NetworkProxyBridge; + private timeoutManager: TimeoutManager; + private acmeManager: AcmeManager; + private portRangeManager: PortRangeManager; + private connectionHandler: ConnectionHandler; + + constructor(settingsArg: IPortProxySettings) { + // Set reasonable defaults for all settings + this.settings = { + ...settingsArg, + targetIP: settingsArg.targetIP || 'localhost', + initialDataTimeout: settingsArg.initialDataTimeout || 120000, + socketTimeout: settingsArg.socketTimeout || 3600000, + inactivityCheckInterval: settingsArg.inactivityCheckInterval || 60000, + maxConnectionLifetime: settingsArg.maxConnectionLifetime || 86400000, + inactivityTimeout: settingsArg.inactivityTimeout || 14400000, + gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000, + noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true, + keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true, + keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 10000, + maxPendingDataSize: settingsArg.maxPendingDataSize || 10 * 1024 * 1024, + disableInactivityCheck: settingsArg.disableInactivityCheck || false, + enableKeepAliveProbes: + settingsArg.enableKeepAliveProbes !== undefined ? settingsArg.enableKeepAliveProbes : true, + enableDetailedLogging: settingsArg.enableDetailedLogging || false, + enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false, + enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false, + allowSessionTicket: + settingsArg.allowSessionTicket !== undefined ? settingsArg.allowSessionTicket : true, + maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100, + connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300, + keepAliveTreatment: settingsArg.keepAliveTreatment || 'extended', + keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6, + extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000, + networkProxyPort: settingsArg.networkProxyPort || 8443, + acme: settingsArg.acme || { + enabled: false, + port: 80, + contactEmail: 'admin@example.com', + useProduction: false, + renewThresholdDays: 30, + autoRenew: true, + certificateStore: './certs', + skipConfiguredCerts: false, + }, + }; + + // Initialize component managers + this.timeoutManager = new TimeoutManager(this.settings); + this.securityManager = new SecurityManager(this.settings); + this.connectionManager = new ConnectionManager( + this.settings, + this.securityManager, + this.timeoutManager + ); + this.domainConfigManager = new DomainConfigManager(this.settings); + this.tlsManager = new TlsManager(this.settings); + this.networkProxyBridge = new NetworkProxyBridge(this.settings); + this.portRangeManager = new PortRangeManager(this.settings); + this.acmeManager = new AcmeManager(this.settings, this.networkProxyBridge); + + // Initialize connection handler + this.connectionHandler = new ConnectionHandler( + this.settings, + this.connectionManager, + this.securityManager, + this.domainConfigManager, + this.tlsManager, + this.networkProxyBridge, + this.timeoutManager, + this.portRangeManager + ); + } + + /** + * The settings for the port proxy + */ + public settings: IPortProxySettings; + + /** + * Start the proxy server + */ + public async start() { + // Don't start if already shutting down + if (this.isShuttingDown) { + console.log("Cannot start PortProxy while it's shutting down"); + return; + } + + // Initialize and start NetworkProxy if needed + if ( + this.settings.useNetworkProxy && + this.settings.useNetworkProxy.length > 0 + ) { + await this.networkProxyBridge.initialize(); + await this.networkProxyBridge.start(); + } + + // Validate port configuration + const configWarnings = this.portRangeManager.validateConfiguration(); + if (configWarnings.length > 0) { + console.log("Port configuration warnings:"); + for (const warning of configWarnings) { + console.log(` - ${warning}`); + } + } + + // Get listening ports from PortRangeManager + const listeningPorts = this.portRangeManager.getListeningPorts(); + + // Create servers for each port + for (const port of listeningPorts) { + const server = plugins.net.createServer((socket) => { + // Check if shutting down + if (this.isShuttingDown) { + socket.end(); + socket.destroy(); + return; + } + + // Delegate to connection handler + this.connectionHandler.handleConnection(socket); + }).on('error', (err: Error) => { + console.log(`Server Error on port ${port}: ${err.message}`); + }); + + server.listen(port, () => { + const isNetworkProxyPort = this.settings.useNetworkProxy?.includes(port); + console.log( + `PortProxy -> OK: Now listening on port ${port}${ + this.settings.sniEnabled && !isNetworkProxyPort ? ' (SNI passthrough enabled)' : '' + }${isNetworkProxyPort ? ' (NetworkProxy forwarding enabled)' : ''}` + ); + }); + + this.netServers.push(server); + } + + // Set up periodic connection logging and inactivity checks + this.connectionLogger = setInterval(() => { + // Immediately return if shutting down + if (this.isShuttingDown) return; + + // Perform inactivity check + this.connectionManager.performInactivityCheck(); + + // Log connection statistics + const now = Date.now(); + let maxIncoming = 0; + let maxOutgoing = 0; + let tlsConnections = 0; + let nonTlsConnections = 0; + let completedTlsHandshakes = 0; + let pendingTlsHandshakes = 0; + let keepAliveConnections = 0; + let networkProxyConnections = 0; + + // Get connection records for analysis + const connectionRecords = this.connectionManager.getConnections(); + + // Analyze active connections + for (const record of connectionRecords.values()) { + // Track connection stats + if (record.isTLS) { + tlsConnections++; + if (record.tlsHandshakeComplete) { + completedTlsHandshakes++; + } else { + pendingTlsHandshakes++; + } + } else { + nonTlsConnections++; + } + + if (record.hasKeepAlive) { + keepAliveConnections++; + } + + if (record.usingNetworkProxy) { + networkProxyConnections++; + } + + maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime); + if (record.outgoingStartTime) { + maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime); + } + } + + // Get termination stats + const terminationStats = this.connectionManager.getTerminationStats(); + + // Log detailed stats + console.log( + `Active connections: ${connectionRecords.size}. ` + + `Types: TLS=${tlsConnections} (Completed=${completedTlsHandshakes}, Pending=${pendingTlsHandshakes}), ` + + `Non-TLS=${nonTlsConnections}, KeepAlive=${keepAliveConnections}, NetworkProxy=${networkProxyConnections}. ` + + `Longest running: IN=${plugins.prettyMs(maxIncoming)}, OUT=${plugins.prettyMs(maxOutgoing)}. ` + + `Termination stats: ${JSON.stringify({ + IN: terminationStats.incoming, + OUT: terminationStats.outgoing, + })}` + ); + }, this.settings.inactivityCheckInterval || 60000); + + // Make sure the interval doesn't keep the process alive + if (this.connectionLogger.unref) { + this.connectionLogger.unref(); + } + } + + /** + * Stop the proxy server + */ + public async stop() { + console.log('PortProxy shutting down...'); + this.isShuttingDown = true; + + // Stop accepting new connections + const closeServerPromises: Promise[] = this.netServers.map( + (server) => + new Promise((resolve) => { + if (!server.listening) { + resolve(); + return; + } + server.close((err) => { + if (err) { + console.log(`Error closing server: ${err.message}`); + } + resolve(); + }); + }) + ); + + // Stop the connection logger + if (this.connectionLogger) { + clearInterval(this.connectionLogger); + this.connectionLogger = null; + } + + // Wait for servers to close + await Promise.all(closeServerPromises); + console.log('All servers closed. Cleaning up active connections...'); + + // Clean up all active connections + this.connectionManager.clearConnections(); + + // Stop NetworkProxy + await this.networkProxyBridge.stop(); + + // Clear all servers + this.netServers = []; + + console.log('PortProxy shutdown complete.'); + } + + /** + * Updates the domain configurations for the proxy + */ + public async updateDomainConfigs(newDomainConfigs: IDomainConfig[]): Promise { + console.log(`Updating domain configurations (${newDomainConfigs.length} configs)`); + + // Update domain configs in DomainConfigManager + this.domainConfigManager.updateDomainConfigs(newDomainConfigs); + + // If NetworkProxy is initialized, resync the configurations + if (this.networkProxyBridge.getNetworkProxy()) { + await this.networkProxyBridge.syncDomainConfigsToNetworkProxy(); + } + } + + /** + * Updates the ACME certificate settings + */ + public async updateAcmeSettings(acmeSettings: IPortProxySettings['acme']): Promise { + console.log('Updating ACME certificate settings'); + + // Delegate to AcmeManager + await this.acmeManager.updateAcmeSettings(acmeSettings); + } + + /** + * Requests a certificate for a specific domain + */ + public async requestCertificate(domain: string): Promise { + // Delegate to AcmeManager + return this.acmeManager.requestCertificate(domain); + } + + /** + * Get statistics about current connections + */ + public getStatistics(): any { + const connectionRecords = this.connectionManager.getConnections(); + const terminationStats = this.connectionManager.getTerminationStats(); + + let tlsConnections = 0; + let nonTlsConnections = 0; + let keepAliveConnections = 0; + let networkProxyConnections = 0; + + // Analyze active connections + for (const record of connectionRecords.values()) { + if (record.isTLS) tlsConnections++; + else nonTlsConnections++; + if (record.hasKeepAlive) keepAliveConnections++; + if (record.usingNetworkProxy) networkProxyConnections++; + } + + return { + activeConnections: connectionRecords.size, + tlsConnections, + nonTlsConnections, + keepAliveConnections, + networkProxyConnections, + terminationStats + }; + } +} \ No newline at end of file diff --git a/ts/classes.pp.portrangemanager.ts b/ts/classes.pp.portrangemanager.ts new file mode 100644 index 0000000..5872a7d --- /dev/null +++ b/ts/classes.pp.portrangemanager.ts @@ -0,0 +1,214 @@ +import type{ IPortProxySettings } from './classes.pp.interfaces.js'; + +/** + * Manages port ranges and port-based configuration + */ +export class PortRangeManager { + constructor(private settings: IPortProxySettings) {} + + /** + * Get all ports that should be listened on + */ + public getListeningPorts(): Set { + const listeningPorts = new Set(); + + // Always include the main fromPort + listeningPorts.add(this.settings.fromPort); + + // Add ports from global port ranges if defined + if (this.settings.globalPortRanges && this.settings.globalPortRanges.length > 0) { + for (const range of this.settings.globalPortRanges) { + for (let port = range.from; port <= range.to; port++) { + listeningPorts.add(port); + } + } + } + + return listeningPorts; + } + + /** + * Check if a port should use NetworkProxy for forwarding + */ + public shouldUseNetworkProxy(port: number): boolean { + return !!this.settings.useNetworkProxy && this.settings.useNetworkProxy.includes(port); + } + + /** + * Check if port should use global forwarding + */ + public shouldUseGlobalForwarding(port: number): boolean { + return ( + !!this.settings.forwardAllGlobalRanges && + this.isPortInGlobalRanges(port) + ); + } + + /** + * Check if a port is in global ranges + */ + public isPortInGlobalRanges(port: number): boolean { + return ( + this.settings.globalPortRanges && + this.isPortInRanges(port, this.settings.globalPortRanges) + ); + } + + /** + * Check if a port falls within the specified ranges + */ + public isPortInRanges(port: number, ranges: Array<{ from: number; to: number }>): boolean { + return ranges.some((range) => port >= range.from && port <= range.to); + } + + /** + * Get forwarding port for a specific listening port + * This determines what port to connect to on the target + */ + public getForwardingPort(listeningPort: number): number { + // If using global forwarding, forward to the original port + if (this.settings.forwardAllGlobalRanges && this.isPortInGlobalRanges(listeningPort)) { + return listeningPort; + } + + // Otherwise use the configured toPort + return this.settings.toPort; + } + + /** + * Find domain-specific port ranges that include a given port + */ + public findDomainPortRange(port: number): { + domainIndex: number, + range: { from: number, to: number } + } | undefined { + for (let i = 0; i < this.settings.domainConfigs.length; i++) { + const domain = this.settings.domainConfigs[i]; + if (domain.portRanges) { + for (const range of domain.portRanges) { + if (port >= range.from && port <= range.to) { + return { domainIndex: i, range }; + } + } + } + } + return undefined; + } + + /** + * Get a list of all configured ports + * This includes the fromPort, NetworkProxy ports, and ports from all ranges + */ + public getAllConfiguredPorts(): number[] { + const ports = new Set(); + + // Add main listening port + ports.add(this.settings.fromPort); + + // Add NetworkProxy port if configured + if (this.settings.networkProxyPort) { + ports.add(this.settings.networkProxyPort); + } + + // Add NetworkProxy ports + if (this.settings.useNetworkProxy) { + for (const port of this.settings.useNetworkProxy) { + ports.add(port); + } + } + + // Add ACME HTTP challenge port if enabled + if (this.settings.acme?.enabled && this.settings.acme.port) { + ports.add(this.settings.acme.port); + } + + // Add global port ranges + if (this.settings.globalPortRanges) { + for (const range of this.settings.globalPortRanges) { + for (let port = range.from; port <= range.to; port++) { + ports.add(port); + } + } + } + + // Add domain-specific port ranges + for (const domain of this.settings.domainConfigs) { + if (domain.portRanges) { + for (const range of domain.portRanges) { + for (let port = range.from; port <= range.to; port++) { + ports.add(port); + } + } + } + + // Add domain-specific NetworkProxy port if configured + if (domain.useNetworkProxy && domain.networkProxyPort) { + ports.add(domain.networkProxyPort); + } + } + + return Array.from(ports); + } + + /** + * Validate port configuration + * Returns array of warning messages + */ + public validateConfiguration(): string[] { + const warnings: string[] = []; + + // Check for overlapping port ranges + const portMappings = new Map(); + + // Track global port ranges + if (this.settings.globalPortRanges) { + for (const range of this.settings.globalPortRanges) { + for (let port = range.from; port <= range.to; port++) { + if (!portMappings.has(port)) { + portMappings.set(port, []); + } + portMappings.get(port)!.push('Global Port Range'); + } + } + } + + // Track domain-specific port ranges + for (const domain of this.settings.domainConfigs) { + if (domain.portRanges) { + for (const range of domain.portRanges) { + for (let port = range.from; port <= range.to; port++) { + if (!portMappings.has(port)) { + portMappings.set(port, []); + } + portMappings.get(port)!.push(`Domain: ${domain.domains.join(', ')}`); + } + } + } + } + + // Check for ports with multiple mappings + for (const [port, mappings] of portMappings.entries()) { + if (mappings.length > 1) { + warnings.push(`Port ${port} has multiple mappings: ${mappings.join(', ')}`); + } + } + + // Check if main ports are used elsewhere + if (portMappings.has(this.settings.fromPort) && portMappings.get(this.settings.fromPort)!.length > 0) { + warnings.push(`Main listening port ${this.settings.fromPort} is also used in port ranges`); + } + + if (this.settings.networkProxyPort && portMappings.has(this.settings.networkProxyPort)) { + warnings.push(`NetworkProxy port ${this.settings.networkProxyPort} is also used in port ranges`); + } + + // Check ACME port + if (this.settings.acme?.enabled && this.settings.acme.port) { + if (portMappings.has(this.settings.acme.port)) { + warnings.push(`ACME HTTP challenge port ${this.settings.acme.port} is also used in port ranges`); + } + } + + return warnings; + } +} \ No newline at end of file diff --git a/ts/classes.pp.securitymanager.ts b/ts/classes.pp.securitymanager.ts new file mode 100644 index 0000000..287133a --- /dev/null +++ b/ts/classes.pp.securitymanager.ts @@ -0,0 +1,147 @@ +import * as plugins from './plugins.js'; +import type { IPortProxySettings } from './classes.pp.interfaces.js'; + +/** + * Handles security aspects like IP tracking, rate limiting, and authorization + */ +export class SecurityManager { + private connectionsByIP: Map> = new Map(); + private connectionRateByIP: Map = new Map(); + + constructor(private settings: IPortProxySettings) {} + + /** + * Get connections count by IP + */ + public getConnectionCountByIP(ip: string): number { + return this.connectionsByIP.get(ip)?.size || 0; + } + + /** + * Check and update connection rate for an IP + * @returns true if within rate limit, false if exceeding limit + */ + public checkConnectionRate(ip: string): boolean { + const now = Date.now(); + const minute = 60 * 1000; + + if (!this.connectionRateByIP.has(ip)) { + this.connectionRateByIP.set(ip, [now]); + return true; + } + + // Get timestamps and filter out entries older than 1 minute + const timestamps = this.connectionRateByIP.get(ip)!.filter((time) => now - time < minute); + timestamps.push(now); + this.connectionRateByIP.set(ip, timestamps); + + // Check if rate exceeds limit + return timestamps.length <= this.settings.connectionRateLimitPerMinute!; + } + + /** + * Track connection by IP + */ + public trackConnectionByIP(ip: string, connectionId: string): void { + if (!this.connectionsByIP.has(ip)) { + this.connectionsByIP.set(ip, new Set()); + } + this.connectionsByIP.get(ip)!.add(connectionId); + } + + /** + * Remove connection tracking for an IP + */ + public removeConnectionByIP(ip: string, connectionId: string): void { + if (this.connectionsByIP.has(ip)) { + const connections = this.connectionsByIP.get(ip)!; + connections.delete(connectionId); + if (connections.size === 0) { + this.connectionsByIP.delete(ip); + } + } + } + + /** + * Check if an IP is allowed using glob patterns + */ + public isIPAuthorized(ip: string, allowedIPs: string[], blockedIPs: string[] = []): boolean { + // Skip IP validation if allowedIPs is empty + if (!ip || (allowedIPs.length === 0 && blockedIPs.length === 0)) { + return true; + } + + // First check if IP is blocked + if (blockedIPs.length > 0 && this.isGlobIPMatch(ip, blockedIPs)) { + return false; + } + + // Then check if IP is allowed + return this.isGlobIPMatch(ip, allowedIPs); + } + + /** + * Check if the IP matches any of the glob patterns + */ + private isGlobIPMatch(ip: string, patterns: string[]): boolean { + if (!ip || !patterns || patterns.length === 0) return false; + + const normalizeIP = (ip: string): string[] => { + if (!ip) return []; + 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); + if (normalizedIPVariants.length === 0) return false; + + const expandedPatterns = patterns.flatMap(normalizeIP); + return normalizedIPVariants.some((ipVariant) => + expandedPatterns.some((pattern) => plugins.minimatch(ipVariant, pattern)) + ); + } + + /** + * Check if IP should be allowed considering connection rate and max connections + * @returns Object with result and reason + */ + public validateIP(ip: string): { allowed: boolean; reason?: string } { + // Check connection count limit + if ( + this.settings.maxConnectionsPerIP && + this.getConnectionCountByIP(ip) >= this.settings.maxConnectionsPerIP + ) { + return { + allowed: false, + reason: `Maximum connections per IP (${this.settings.maxConnectionsPerIP}) exceeded` + }; + } + + // Check connection rate limit + if ( + this.settings.connectionRateLimitPerMinute && + !this.checkConnectionRate(ip) + ) { + return { + allowed: false, + reason: `Connection rate limit (${this.settings.connectionRateLimitPerMinute}/min) exceeded` + }; + } + + return { allowed: true }; + } + + /** + * Clears all IP tracking data (for shutdown) + */ + public clearIPTracking(): void { + this.connectionsByIP.clear(); + this.connectionRateByIP.clear(); + } +} \ No newline at end of file diff --git a/ts/classes.snihandler.ts b/ts/classes.pp.snihandler.ts similarity index 89% rename from ts/classes.snihandler.ts rename to ts/classes.pp.snihandler.ts index 5b76e4e..922285c 100644 --- a/ts/classes.snihandler.ts +++ b/ts/classes.pp.snihandler.ts @@ -22,114 +22,6 @@ export class SniHandler { private static fragmentedBuffers: Map = new Map(); private static fragmentTimeout: number = 1000; // ms to wait for fragments before cleanup - // Session tracking for tab reactivation scenarios - private static sessionCache: Map< - string, - { - sni: string; - timestamp: number; - clientRandom?: Buffer; - } - > = new Map(); - - // Longer timeout for session cache (24 hours by default) - private static sessionCacheTimeout: number = 24 * 60 * 60 * 1000; // 24 hours in milliseconds - - // Cleanup interval for session cache (run every hour) - private static sessionCleanupInterval: NodeJS.Timeout | null = null; - - /** - * Initialize the session cache cleanup mechanism. - * This should be called during application startup. - */ - public static initSessionCacheCleanup(): void { - if (this.sessionCleanupInterval === null) { - this.sessionCleanupInterval = setInterval(() => { - this.cleanupSessionCache(); - }, 60 * 60 * 1000); // Run every hour - } - } - - /** - * Clean up expired entries from the session cache - */ - private static cleanupSessionCache(): void { - const now = Date.now(); - const expiredKeys: string[] = []; - - this.sessionCache.forEach((session, key) => { - if (now - session.timestamp > this.sessionCacheTimeout) { - expiredKeys.push(key); - } - }); - - expiredKeys.forEach((key) => { - this.sessionCache.delete(key); - }); - } - - /** - * Create a client identity key for session tracking - * Uses source IP and optional client random for uniqueness - * - * @param sourceIp - Client IP address - * @param clientRandom - Optional TLS client random value - * @returns A string key for the session cache - */ - private static createClientKey(sourceIp: string, clientRandom?: Buffer): string { - if (clientRandom) { - // If we have the client random, use it for more precise tracking - return `${sourceIp}:${clientRandom.toString('hex')}`; - } - // Fall back to just IP-based tracking - return sourceIp; - } - - /** - * Store SNI information in the session cache - * - * @param sourceIp - Client IP address - * @param sni - The extracted SNI value - * @param clientRandom - Optional TLS client random value - */ - private static cacheSession(sourceIp: string, sni: string, clientRandom?: Buffer): void { - const key = this.createClientKey(sourceIp, clientRandom); - this.sessionCache.set(key, { - sni, - timestamp: Date.now(), - clientRandom, - }); - } - - /** - * Retrieve SNI information from the session cache - * - * @param sourceIp - Client IP address - * @param clientRandom - Optional TLS client random value - * @returns The cached SNI or undefined if not found - */ - private static getCachedSession(sourceIp: string, clientRandom?: Buffer): string | undefined { - // Try with client random first for precision - if (clientRandom) { - const preciseKey = this.createClientKey(sourceIp, clientRandom); - const preciseSession = this.sessionCache.get(preciseKey); - if (preciseSession) { - return preciseSession.sni; - } - } - - // Fall back to IP-only lookup - const ipKey = this.createClientKey(sourceIp); - const session = this.sessionCache.get(ipKey); - if (session) { - // Update the timestamp to keep the session alive - session.timestamp = Date.now(); - return session.sni; - } - - return undefined; - } - /** * Extract the client random value from a ClientHello message * @@ -1172,7 +1064,6 @@ export class SniHandler { * 4. Fragmented ClientHello messages * 5. TLS 1.3 Early Data (0-RTT) * 6. Chrome's connection racing behaviors - * 7. Tab reactivation patterns with session cache * * @param buffer - The buffer containing the TLS ClientHello message * @param connectionInfo - Optional connection information for fragment handling @@ -1235,19 +1126,10 @@ export class SniHandler { const standardSni = this.extractSNI(processBuffer, enableLogging); if (standardSni) { log(`Found standard SNI: ${standardSni}`); - - // If we extracted a standard SNI, cache it for future use - if (connectionInfo?.sourceIp) { - const clientRandom = this.extractClientRandom(processBuffer); - this.cacheSession(connectionInfo.sourceIp, standardSni, clientRandom); - log(`Cached SNI for future reference: ${standardSni}`); - } - return standardSni; } // Check for session resumption when standard SNI extraction fails - // This may help in chained proxy scenarios if (this.isClientHello(processBuffer)) { const resumptionInfo = this.hasSessionResumption(processBuffer, enableLogging); @@ -1258,31 +1140,11 @@ export class SniHandler { const pskSni = this.extractSNIFromPSKExtension(processBuffer, enableLogging); if (pskSni) { log(`Extracted SNI from PSK extension: ${pskSni}`); - - // Cache this SNI - if (connectionInfo?.sourceIp) { - const clientRandom = this.extractClientRandom(processBuffer); - this.cacheSession(connectionInfo.sourceIp, pskSni, clientRandom); - } - return pskSni; } - - // If session resumption has SNI in a non-standard location, - // we need to apply heuristics - if (connectionInfo?.sourceIp) { - const cachedSni = this.getCachedSession(connectionInfo.sourceIp); - if (cachedSni) { - log(`Using cached SNI for session resumption: ${cachedSni}`); - return cachedSni; - } - } } } - // Try tab reactivation and other recovery methods... - // (existing code remains unchanged) - // Log detailed info about the ClientHello when SNI extraction fails if (this.isClientHello(processBuffer) && enableLogging) { log(`SNI extraction failed for ClientHello. Buffer details:`); @@ -1303,7 +1165,6 @@ export class SniHandler { } } - // Existing code for fallback methods continues... return undefined; } @@ -1313,7 +1174,7 @@ export class SniHandler { * * The method uses connection tracking to handle fragmented ClientHello * messages and various TLS 1.3 behaviors, including Chrome's connection - * racing patterns and tab reactivation behaviors. + * racing patterns. * * @param buffer - The buffer containing TLS data * @param connectionInfo - Connection metadata (IPs and ports) @@ -1321,7 +1182,6 @@ export class SniHandler { * @param cachedSni - Optional cached SNI from previous connections (for racing detection) * @returns The extracted server name or undefined if not found or more data needed */ - public static processTlsPacket( buffer: Buffer, connectionInfo: { @@ -1363,13 +1223,6 @@ export class SniHandler { return cachedSni; } - // Otherwise check our session cache - const sessionCachedSni = this.getCachedSession(connectionInfo.sourceIp); - if (sessionCachedSni) { - log(`Using session-cached SNI for application data: ${sessionCachedSni}`); - return sessionCachedSni; - } - log('Application data packet without cached SNI, cannot determine hostname'); return undefined; } @@ -1385,9 +1238,6 @@ export class SniHandler { const standardSni = this.extractSNI(buffer, enableLogging); if (standardSni) { log(`Found standard SNI in session resumption: ${standardSni}`); - - // Cache this SNI - this.cacheSession(connectionInfo.sourceIp, standardSni); return standardSni; } @@ -1396,7 +1246,6 @@ export class SniHandler { const pskSni = this.extractSNIFromPSKExtension(buffer, enableLogging); if (pskSni) { log(`Extracted SNI from PSK extension: ${pskSni}`); - this.cacheSession(connectionInfo.sourceIp, pskSni); return pskSni; } @@ -1430,13 +1279,6 @@ export class SniHandler { } } - // If we still don't have SNI, check for cached sessions - const cachedSni = this.getCachedSession(connectionInfo.sourceIp); - if (cachedSni) { - log(`Using cached SNI for session resumption: ${cachedSni}`); - return cachedSni; - } - log(`Session resumption without extractable SNI`); // If allowSessionTicket=false, should be rejected by caller } @@ -1451,19 +1293,10 @@ export class SniHandler { } // If we couldn't extract an SNI, check if this is a valid ClientHello - // If it is, but we couldn't get an SNI, it might be a fragment or - // a connection race situation if (this.isClientHello(buffer)) { - // Check if we have a cached session for this IP - const sessionCachedSni = this.getCachedSession(connectionInfo.sourceIp); - if (sessionCachedSni) { - log(`Using session cache for ClientHello without SNI: ${sessionCachedSni}`); - return sessionCachedSni; - } - log('Valid ClientHello detected, but no SNI extracted - might need more data'); } return undefined; } -} +} \ No newline at end of file diff --git a/ts/classes.pp.timeoutmanager.ts b/ts/classes.pp.timeoutmanager.ts new file mode 100644 index 0000000..3bc71a3 --- /dev/null +++ b/ts/classes.pp.timeoutmanager.ts @@ -0,0 +1,190 @@ +import type { IConnectionRecord, IPortProxySettings } from './classes.pp.interfaces.js'; + +/** + * Manages timeouts and inactivity tracking for connections + */ +export class TimeoutManager { + constructor(private settings: IPortProxySettings) {} + + /** + * Ensure timeout values don't exceed Node.js max safe integer + */ + public ensureSafeTimeout(timeout: number): number { + const MAX_SAFE_TIMEOUT = 2147483647; // Maximum safe value (2^31 - 1) + return Math.min(Math.floor(timeout), MAX_SAFE_TIMEOUT); + } + + /** + * Generate a slightly randomized timeout to prevent thundering herd + */ + public randomizeTimeout(baseTimeout: number, variationPercent: number = 5): number { + const safeBaseTimeout = this.ensureSafeTimeout(baseTimeout); + const variation = safeBaseTimeout * (variationPercent / 100); + return this.ensureSafeTimeout( + safeBaseTimeout + Math.floor(Math.random() * variation * 2) - variation + ); + } + + /** + * Update connection activity timestamp + */ + public updateActivity(record: IConnectionRecord): void { + record.lastActivity = Date.now(); + + // Clear any inactivity warning + if (record.inactivityWarningIssued) { + record.inactivityWarningIssued = false; + } + } + + /** + * Calculate effective inactivity timeout based on connection type + */ + public getEffectiveInactivityTimeout(record: IConnectionRecord): number { + let effectiveTimeout = this.settings.inactivityTimeout || 14400000; // 4 hours default + + // For immortal keep-alive connections, use an extremely long timeout + if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') { + return Number.MAX_SAFE_INTEGER; + } + + // For extended keep-alive connections, apply multiplier + if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') { + const multiplier = this.settings.keepAliveInactivityMultiplier || 6; + effectiveTimeout = effectiveTimeout * multiplier; + } + + return this.ensureSafeTimeout(effectiveTimeout); + } + + /** + * Calculate effective max lifetime based on connection type + */ + public getEffectiveMaxLifetime(record: IConnectionRecord): number { + // Use domain-specific timeout if available + const baseTimeout = record.domainConfig?.connectionTimeout || + this.settings.maxConnectionLifetime || + 86400000; // 24 hours default + + // For immortal keep-alive connections, use an extremely long lifetime + if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') { + return Number.MAX_SAFE_INTEGER; + } + + // For extended keep-alive connections, use the extended lifetime setting + if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') { + return this.ensureSafeTimeout( + this.settings.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000 // 7 days default + ); + } + + // Apply randomization if enabled + if (this.settings.enableRandomizedTimeouts) { + return this.randomizeTimeout(baseTimeout); + } + + return this.ensureSafeTimeout(baseTimeout); + } + + /** + * Setup connection timeout + * @returns The cleanup timer + */ + public setupConnectionTimeout( + record: IConnectionRecord, + onTimeout: (record: IConnectionRecord, reason: string) => void + ): NodeJS.Timeout { + // Clear any existing timer + if (record.cleanupTimer) { + clearTimeout(record.cleanupTimer); + } + + // Calculate effective timeout + const effectiveLifetime = this.getEffectiveMaxLifetime(record); + + // Set up the timeout + const timer = setTimeout(() => { + // Call the provided callback + onTimeout(record, 'connection_timeout'); + }, effectiveLifetime); + + // Make sure timeout doesn't keep the process alive + if (timer.unref) { + timer.unref(); + } + + return timer; + } + + /** + * Check for inactivity on a connection + * @returns Object with check results + */ + public checkInactivity(record: IConnectionRecord): { + isInactive: boolean; + shouldWarn: boolean; + inactivityTime: number; + effectiveTimeout: number; + } { + // Skip for connections with inactivity check disabled + if (this.settings.disableInactivityCheck) { + return { + isInactive: false, + shouldWarn: false, + inactivityTime: 0, + effectiveTimeout: 0 + }; + } + + // Skip for immortal keep-alive connections + if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') { + return { + isInactive: false, + shouldWarn: false, + inactivityTime: 0, + effectiveTimeout: 0 + }; + } + + const now = Date.now(); + const inactivityTime = now - record.lastActivity; + const effectiveTimeout = this.getEffectiveInactivityTimeout(record); + + // Check if inactive + const isInactive = inactivityTime > effectiveTimeout; + + // For keep-alive connections, we should warn first + const shouldWarn = record.hasKeepAlive && + isInactive && + !record.inactivityWarningIssued; + + return { + isInactive, + shouldWarn, + inactivityTime, + effectiveTimeout + }; + } + + /** + * Apply socket timeout settings + */ + public applySocketTimeouts(record: IConnectionRecord): void { + // Skip for immortal keep-alive connections + if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') { + // Disable timeouts completely for immortal connections + record.incoming.setTimeout(0); + if (record.outgoing) { + record.outgoing.setTimeout(0); + } + return; + } + + // Apply normal timeouts + const timeout = this.ensureSafeTimeout(this.settings.socketTimeout || 3600000); // 1 hour default + record.incoming.setTimeout(timeout); + if (record.outgoing) { + record.outgoing.setTimeout(timeout); + } + } +} \ No newline at end of file diff --git a/ts/classes.pp.tlsmanager.ts b/ts/classes.pp.tlsmanager.ts new file mode 100644 index 0000000..a2e71f1 --- /dev/null +++ b/ts/classes.pp.tlsmanager.ts @@ -0,0 +1,206 @@ +import * as plugins from './plugins.js'; +import type { IPortProxySettings } from './classes.pp.interfaces.js'; +import { SniHandler } from './classes.pp.snihandler.js'; + +/** + * Interface for connection information used for SNI extraction + */ +interface IConnectionInfo { + sourceIp: string; + sourcePort: number; + destIp: string; + destPort: number; +} + +/** + * Manages TLS-related operations including SNI extraction and validation + */ +export class TlsManager { + constructor(private settings: IPortProxySettings) {} + + /** + * Check if a data chunk appears to be a TLS handshake + */ + public isTlsHandshake(chunk: Buffer): boolean { + return SniHandler.isTlsHandshake(chunk); + } + + /** + * Check if a data chunk appears to be a TLS ClientHello + */ + public isClientHello(chunk: Buffer): boolean { + return SniHandler.isClientHello(chunk); + } + + /** + * Extract Server Name Indication (SNI) from TLS handshake + */ + public extractSNI( + chunk: Buffer, + connInfo: IConnectionInfo, + previousDomain?: string + ): string | undefined { + // Use the SniHandler to process the TLS packet + return SniHandler.processTlsPacket( + chunk, + connInfo, + this.settings.enableTlsDebugLogging || false, + previousDomain + ); + } + + /** + * Handle session resumption attempts + */ + public handleSessionResumption( + chunk: Buffer, + connectionId: string, + hasSNI: boolean + ): { shouldBlock: boolean; reason?: string } { + // Skip if session tickets are allowed + if (this.settings.allowSessionTicket !== false) { + return { shouldBlock: false }; + } + + // Check for session resumption attempt + const resumptionInfo = SniHandler.hasSessionResumption( + chunk, + this.settings.enableTlsDebugLogging || false + ); + + // If this is a resumption attempt without SNI, block it + if (resumptionInfo.isResumption && !hasSNI && !resumptionInfo.hasSNI) { + if (this.settings.enableTlsDebugLogging) { + console.log( + `[${connectionId}] Session resumption detected without SNI and allowSessionTicket=false. ` + + `Terminating connection to force new TLS handshake.` + ); + } + return { + shouldBlock: true, + reason: 'session_ticket_blocked' + }; + } + + return { shouldBlock: false }; + } + + /** + * Check for SNI mismatch during renegotiation + */ + public checkRenegotiationSNI( + chunk: Buffer, + connInfo: IConnectionInfo, + expectedDomain: string, + connectionId: string + ): { hasMismatch: boolean; extractedSNI?: string } { + // Only process if this looks like a TLS ClientHello + if (!this.isClientHello(chunk)) { + return { hasMismatch: false }; + } + + try { + // Extract SNI with renegotiation support + const newSNI = SniHandler.extractSNIWithResumptionSupport( + chunk, + connInfo, + this.settings.enableTlsDebugLogging || false + ); + + // Skip if no SNI was found + if (!newSNI) return { hasMismatch: false }; + + // Check for SNI mismatch + if (newSNI !== expectedDomain) { + if (this.settings.enableTlsDebugLogging) { + console.log( + `[${connectionId}] Renegotiation with different SNI: ${expectedDomain} -> ${newSNI}. ` + + `Terminating connection - SNI domain switching is not allowed.` + ); + } + return { hasMismatch: true, extractedSNI: newSNI }; + } else if (this.settings.enableTlsDebugLogging) { + console.log( + `[${connectionId}] Renegotiation detected with same SNI: ${newSNI}. Allowing.` + ); + } + } catch (err) { + console.log( + `[${connectionId}] Error processing ClientHello: ${err}. Allowing connection to continue.` + ); + } + + return { hasMismatch: false }; + } + + /** + * Create a renegotiation handler function for a connection + */ + public createRenegotiationHandler( + connectionId: string, + lockedDomain: string, + connInfo: IConnectionInfo, + onMismatch: (connectionId: string, reason: string) => void + ): (chunk: Buffer) => void { + return (chunk: Buffer) => { + const result = this.checkRenegotiationSNI(chunk, connInfo, lockedDomain, connectionId); + if (result.hasMismatch) { + onMismatch(connectionId, 'sni_mismatch'); + } + }; + } + + /** + * Analyze TLS connection for browser fingerprinting + * This helps identify browser vs non-browser connections + */ + public analyzeClientHello(chunk: Buffer): { + isBrowserConnection: boolean; + isRenewal: boolean; + hasSNI: boolean; + } { + // Default result + const result = { + isBrowserConnection: false, + isRenewal: false, + hasSNI: false + }; + + try { + // Check if it's a ClientHello + if (!this.isClientHello(chunk)) { + return result; + } + + // Check for session resumption + const resumptionInfo = SniHandler.hasSessionResumption( + chunk, + this.settings.enableTlsDebugLogging || false + ); + + // Extract SNI + const sni = SniHandler.extractSNI( + chunk, + this.settings.enableTlsDebugLogging || false + ); + + // Update result + result.isRenewal = resumptionInfo.isResumption; + result.hasSNI = !!sni; + + // Browsers typically: + // 1. Send SNI extension + // 2. Have a variety of extensions (ALPN, etc.) + // 3. Use standard cipher suites + // ...more complex heuristics could be implemented here + + // Simple heuristic: presence of SNI suggests browser + result.isBrowserConnection = !!sni; + + return result; + } catch (err) { + console.log(`Error analyzing ClientHello: ${err}`); + return result; + } + } +} \ No newline at end of file diff --git a/ts/index.ts b/ts/index.ts index 804308f..ff6c59d 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -1,6 +1,6 @@ export * from './classes.iptablesproxy.js'; export * from './classes.networkproxy.js'; -export * from './classes.portproxy.js'; +export * from './classes.pp.portproxy.js'; export * from './classes.port80handler.js'; export * from './classes.sslredirect.js'; -export * from './classes.snihandler.js'; +export * from './classes.pp.snihandler.js';