diff --git a/changelog.md b/changelog.md index 4bca20b..a249107 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2025-03-11 - 3.34.0 - feat(core) +Improve wildcard domain matching and enhance NetworkProxy integration in PortProxy. Added support for TLD wildcards and complex wildcard patterns in the router, and refactored TLS renegotiation handling for stricter SNI enforcement. + +- Added support for TLD wildcard matching (e.g., 'example.*') to improve domain routing. +- Implemented complex wildcard pattern matching (e.g., '*.lossless*') in the router. +- Enhanced NetworkProxy integration by initializing a single NetworkProxy instance and forwarding TLS connections accordingly. +- Refactored TLS renegotiation handling to terminate connections on SNI mismatch for stricter enforcement. +- Updated tests to cover the new wildcard matching scenarios. + ## 2025-03-11 - 3.33.0 - feat(portproxy) Add browser-friendly mode and SNI renegotiation configuration options to PortProxy diff --git a/test/test.router.ts b/test/test.router.ts index 25e1b70..40bfdab 100644 --- a/test/test.router.ts +++ b/test/test.router.ts @@ -197,6 +197,52 @@ tap.test('should match wildcard subdomains', async () => { expect(result).toEqual(wildcardConfig); }); +// Test TLD wildcards (example.*) +tap.test('should match TLD wildcards', async () => { + const tldWildcardConfig = createProxyConfig('example.*'); + router.setNewProxyConfigs([tldWildcardConfig]); + + // Test that example.com matches example.* + const req1 = createMockRequest('example.com'); + const result1 = router.routeReq(req1); + expect(result1).toBeTruthy(); + expect(result1).toEqual(tldWildcardConfig); + + // Test that example.org matches example.* + const req2 = createMockRequest('example.org'); + const result2 = router.routeReq(req2); + expect(result2).toBeTruthy(); + expect(result2).toEqual(tldWildcardConfig); + + // Test that subdomain.example.com doesn't match example.* + const req3 = createMockRequest('subdomain.example.com'); + const result3 = router.routeReq(req3); + expect(result3).toBeUndefined(); +}); + +// Test complex pattern matching (*.lossless*) +tap.test('should match complex wildcard patterns', async () => { + const complexWildcardConfig = createProxyConfig('*.lossless*'); + router.setNewProxyConfigs([complexWildcardConfig]); + + // Test that sub.lossless.com matches *.lossless* + const req1 = createMockRequest('sub.lossless.com'); + const result1 = router.routeReq(req1); + expect(result1).toBeTruthy(); + expect(result1).toEqual(complexWildcardConfig); + + // Test that api.lossless.org matches *.lossless* + const req2 = createMockRequest('api.lossless.org'); + const result2 = router.routeReq(req2); + expect(result2).toBeTruthy(); + expect(result2).toEqual(complexWildcardConfig); + + // Test that losslessapi.com matches *.lossless* + const req3 = createMockRequest('losslessapi.com'); + const result3 = router.routeReq(req3); + expect(result3).toBeUndefined(); // Should not match as it doesn't have a subdomain +}); + // Test default configuration fallback tap.test('should fall back to default configuration', async () => { const defaultConfig = createProxyConfig('*'); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 2e29b18..8b7ce0f 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.33.0', + version: '3.34.0', description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.' } diff --git a/ts/classes.portproxy.ts b/ts/classes.portproxy.ts index eefb7a8..692de2c 100644 --- a/ts/classes.portproxy.ts +++ b/ts/classes.portproxy.ts @@ -10,10 +10,6 @@ export interface IDomainConfig { portRanges?: Array<{ from: number; to: number }>; // Optional port ranges // Allow domain-specific timeout override connectionTimeout?: number; // Connection timeout override (ms) - - // New properties for NetworkProxy integration - useNetworkProxy?: boolean; // When true, forwards TLS connections to NetworkProxy - networkProxyIndex?: number; // Optional index to specify which NetworkProxy to use (defaults to 0) } /** Port proxy settings including global allowed port ranges */ @@ -61,12 +57,8 @@ export interface IPortProxySettings extends plugins.tls.TlsOptions { extendedKeepAliveLifetime?: number; // Extended lifetime for keep-alive connections (ms) // New property for NetworkProxy integration - networkProxies?: NetworkProxy[]; // Array of NetworkProxy instances to use for TLS termination - - // Browser optimization settings - browserFriendlyMode?: boolean; // Optimizes handling for browser connections - allowRenegotiationWithDifferentSNI?: boolean; // Allows SNI changes during renegotiation - relatedDomainPatterns?: string[][]; // Patterns for domains that should be allowed to share connections + useNetworkProxy?: number[]; // Array of ports to forward to NetworkProxy + networkProxyPort?: number; // Port where NetworkProxy is listening (default: 8443) } /** @@ -102,11 +94,10 @@ interface IConnectionRecord { incomingTerminationReason?: string | null; // Reason for incoming termination outgoingTerminationReason?: string | null; // Reason for outgoing termination - // New field for NetworkProxy tracking + // NetworkProxy tracking usingNetworkProxy?: boolean; // Whether this connection is using a NetworkProxy - networkProxyIndex?: number; // Which NetworkProxy instance is being used - // New field for renegotiation handler + // Renegotiation handler renegotiationHandler?: (chunk: Buffer) => void; // Handler for renegotiation detection // Browser connection tracking @@ -301,35 +292,6 @@ function isClientHello(buffer: Buffer): boolean { } } -/** - * Checks if two domains are related based on configured patterns - * @param domain1 - First domain name - * @param domain2 - Second domain name - * @param relatedPatterns - Array of domain pattern groups where domains in the same group are considered related - * @returns true if domains are related, false otherwise - */ -function areDomainsRelated( - domain1: string, - domain2: string, - relatedPatterns?: string[][] -): boolean { - // Only exact same domains or empty domains are automatically related - if (!domain1 || !domain2 || domain1 === domain2) return true; - - // Check against configured related domain patterns - the ONLY source of truth - if (relatedPatterns && relatedPatterns.length > 0) { - for (const patternGroup of relatedPatterns) { - const domain1Matches = patternGroup.some((pattern) => plugins.minimatch(domain1, pattern)); - const domain2Matches = patternGroup.some((pattern) => plugins.minimatch(domain2, pattern)); - - if (domain1Matches && domain2Matches) return true; - } - } - - // If no patterns match, domains are not related - return false; -} - // 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); @@ -413,8 +375,8 @@ export class PortProxy { private connectionsByIP: Map> = new Map(); private connectionRateByIP: Map = new Map(); - // New property to store NetworkProxy instances - private networkProxies: NetworkProxy[] = []; + // NetworkProxy instance for TLS termination + private networkProxy: NetworkProxy | null = null; constructor(settingsArg: IPortProxySettings) { // Set reasonable defaults for all settings @@ -434,34 +396,49 @@ export class PortProxy { // Socket optimization settings noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true, keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true, - keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 10000, // 10 seconds (reduced for responsiveness) - maxPendingDataSize: settingsArg.maxPendingDataSize || 10 * 1024 * 1024, // 10MB to handle large TLS handshakes + 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, // Enable by default + enableKeepAliveProbes: settingsArg.enableKeepAliveProbes !== undefined + ? settingsArg.enableKeepAliveProbes : true, enableDetailedLogging: settingsArg.enableDetailedLogging || false, enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false, - enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false, // Disable randomization by default + enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false, // Rate limiting defaults - maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100, // 100 connections per IP - connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300, // 300 per minute + maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100, + connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300, // Enhanced keep-alive settings - keepAliveTreatment: settingsArg.keepAliveTreatment || 'extended', // Extended by default - keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6, // 6x normal inactivity timeout + keepAliveTreatment: settingsArg.keepAliveTreatment || 'extended', + keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6, extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000, // 7 days - - // Browser optimization settings (new) - browserFriendlyMode: settingsArg.browserFriendlyMode || true, // On by default - allowRenegotiationWithDifferentSNI: settingsArg.allowRenegotiationWithDifferentSNI || false, // Off by default - relatedDomainPatterns: settingsArg.relatedDomainPatterns || [], // Empty by default + + // NetworkProxy settings + networkProxyPort: settingsArg.networkProxyPort || 8443, // Default NetworkProxy port }; - // Store NetworkProxy instances if provided - this.networkProxies = settingsArg.networkProxies || []; + // Initialize NetworkProxy if enabled + if (this.settings.useNetworkProxy && this.settings.useNetworkProxy.length > 0) { + this.initializeNetworkProxy(); + } + } + + /** + * Initialize NetworkProxy instance + */ + private initializeNetworkProxy(): void { + if (!this.networkProxy) { + this.networkProxy = new NetworkProxy({ + port: this.settings.networkProxyPort!, + portProxyIntegration: true, + logLevel: this.settings.enableDetailedLogging ? 'debug' : 'info' + }); + + console.log(`Initialized NetworkProxy on port ${this.settings.networkProxyPort}`); + } } /** @@ -469,45 +446,36 @@ export class PortProxy { * @param connectionId - Unique connection identifier * @param socket - The incoming client socket * @param record - The connection record - * @param domainConfig - The domain configuration * @param initialData - Initial data chunk (TLS ClientHello) - * @param serverName - SNI hostname (if available) */ private forwardToNetworkProxy( connectionId: string, socket: plugins.net.Socket, record: IConnectionRecord, - domainConfig: IDomainConfig, - initialData: Buffer, - serverName?: string + initialData: Buffer ): void { - // Determine which NetworkProxy to use - const proxyIndex = - domainConfig.networkProxyIndex !== undefined ? domainConfig.networkProxyIndex : 0; - - // Validate the NetworkProxy index - if (proxyIndex < 0 || proxyIndex >= this.networkProxies.length) { + // Ensure NetworkProxy is initialized + if (!this.networkProxy) { console.log( - `[${connectionId}] Invalid NetworkProxy index: ${proxyIndex}. Using fallback direct connection.` + `[${connectionId}] NetworkProxy not initialized. Using fallback direct connection.` ); // Fall back to direct connection return this.setupDirectConnection( connectionId, socket, record, - domainConfig, - serverName, + undefined, + undefined, initialData ); } - const networkProxy = this.networkProxies[proxyIndex]; - const proxyPort = networkProxy.getListeningPort(); + const proxyPort = this.networkProxy.getListeningPort(); const proxyHost = 'localhost'; // Assuming NetworkProxy runs locally if (this.settings.enableDetailedLogging) { console.log( - `[${connectionId}] Forwarding TLS connection to NetworkProxy[${proxyIndex}] at ${proxyHost}:${proxyPort}` + `[${connectionId}] Forwarding TLS connection to NetworkProxy at ${proxyHost}:${proxyPort}` ); } @@ -521,7 +489,6 @@ export class PortProxy { record.outgoing = proxySocket; record.outgoingStartTime = Date.now(); record.usingNetworkProxy = true; - record.networkProxyIndex = proxyIndex; // Set up error handlers proxySocket.on('error', (err) => { @@ -565,7 +532,7 @@ export class PortProxy { if (this.settings.enableDetailedLogging) { console.log( - `[${connectionId}] TLS connection successfully forwarded to NetworkProxy[${proxyIndex}]` + `[${connectionId}] TLS connection successfully forwarded to NetworkProxy` ); } }); @@ -886,11 +853,11 @@ export class PortProxy { record.pendingData = []; record.pendingDataSize = 0; - // Add the renegotiation handler for SNI validation, with browser-friendly improvements + // Add the renegotiation handler for SNI validation with strict domain enforcement if (serverName) { // Define a handler for checking renegotiation with improved detection const renegotiationHandler = (renegChunk: Buffer) => { - // Only process if this looks like a TLS ClientHello (more precise than just checking for type 22) + // Only process if this looks like a TLS ClientHello if (isClientHello(renegChunk)) { try { // Extract SNI from ClientHello @@ -899,44 +866,14 @@ export class PortProxy { // Skip if no SNI was found if (!newSNI) return; - // Handle SNI change during renegotiation + // Handle SNI change during renegotiation - always terminate for domain switches if (newSNI !== record.lockedDomain) { - // Track domain switches for browser connections - if (!record.domainSwitches) record.domainSwitches = 0; - record.domainSwitches++; - - // Check if this is a normal behavior of browser connection reuse - const isRelatedDomain = areDomainsRelated( - newSNI, - record.lockedDomain || '', - this.settings.relatedDomainPatterns + // 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.` ); - - // Decide how to handle the SNI change based on settings - if (this.settings.browserFriendlyMode && isRelatedDomain) { - console.log( - `[${connectionId}] Browser domain switch detected: ${record.lockedDomain} -> ${newSNI}. ` + - `Domains are related, allowing connection to continue (domain switch #${record.domainSwitches}).` - ); - - // Update the locked domain to the new one - record.lockedDomain = newSNI; - } else if (this.settings.allowRenegotiationWithDifferentSNI) { - console.log( - `[${connectionId}] Renegotiation with different SNI: ${record.lockedDomain} -> ${newSNI}. ` + - `Allowing due to allowRenegotiationWithDifferentSNI setting.` - ); - - // Update the locked domain to the new one - record.lockedDomain = newSNI; - } else { - // Standard strict behavior - terminate connection on SNI mismatch - console.log( - `[${connectionId}] Renegotiation with different SNI: ${record.lockedDomain} -> ${newSNI}. ` + - `Terminating connection. Enable browserFriendlyMode to allow this.` - ); - this.initiateCleanupOnce(record, 'sni_mismatch'); - } + this.initiateCleanupOnce(record, 'sni_mismatch'); } else if (this.settings.enableDetailedLogging) { console.log( `[${connectionId}] Renegotiation detected with same SNI: ${newSNI}. Allowing.` @@ -1201,7 +1138,7 @@ export class PortProxy { `TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${ record.hasKeepAlive ? 'Yes' : 'No' }` + - `${record.usingNetworkProxy ? `, NetworkProxy: ${record.networkProxyIndex}` : ''}` + + `${record.usingNetworkProxy ? ', Using NetworkProxy' : ''}` + `${record.domainSwitches ? `, Domain switches: ${record.domainSwitches}` : ''}` ); } else { @@ -1341,6 +1278,12 @@ export class PortProxy { return; } + // Start NetworkProxy if configured + if (this.networkProxy) { + await this.networkProxy.start(); + console.log(`NetworkProxy started on port ${this.settings.networkProxyPort}`); + } + // Define a unified connection handler for all listening ports. const connectionHandler = (socket: plugins.net.Socket) => { if (this.isShuttingDown) { @@ -1401,12 +1344,12 @@ export class PortProxy { incomingTerminationReason: null, outgoingTerminationReason: null, - // Initialize NetworkProxy tracking fields + // Initialize NetworkProxy tracking usingNetworkProxy: false, // Initialize browser connection tracking - isBrowserConnection: this.settings.browserFriendlyMode, // Assume browser if browserFriendlyMode is enabled - domainSwitches: 0, // Track domain switches + isBrowserConnection: false, + domainSwitches: 0, }; // Apply keep-alive settings if enabled @@ -1443,7 +1386,6 @@ export class PortProxy { console.log( `[${connectionId}] New connection from ${remoteIP} on port ${localPort}. ` + `Keep-Alive: ${connectionRecord.hasKeepAlive ? 'Enabled' : 'Disabled'}. ` + - `Mode: ${this.settings.browserFriendlyMode ? 'Browser-friendly' : 'Standard'}. ` + `Active connections: ${this.connectionRecords.size}` ); } else { @@ -1452,23 +1394,16 @@ export class PortProxy { ); } - let initialDataReceived = false; + // Check if this connection should be forwarded directly to NetworkProxy based on port + const shouldUseNetworkProxy = this.settings.useNetworkProxy && + this.settings.useNetworkProxy.includes(localPort); - // 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); - }; + if (shouldUseNetworkProxy) { + // For NetworkProxy ports, we want to capture the TLS handshake and forward directly + let initialDataReceived = false; - // Set an initial timeout for SNI data if needed - let initialTimeout: NodeJS.Timeout | null = null; - if (this.settings.sniEnabled) { - initialTimeout = setTimeout(() => { + // Set an initial timeout for handshake data + let initialTimeout: NodeJS.Timeout | null = setTimeout(() => { if (!initialDataReceived) { console.log( `[${connectionId}] Initial data timeout (${this.settings.initialDataTimeout}ms) for connection from ${remoteIP} on port ${localPort}` @@ -1486,283 +1421,331 @@ export class PortProxy { 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 && 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 - extractSNI(chunk, true); - } - } - }); - - /** - * Sets up the connection to the target host or NetworkProxy. - * @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 && 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; - - // 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( - ', ' - )}` - ); - } - - // Check if we should forward this to a NetworkProxy - if ( - isTlsHandshakeDetected && - domainConfig.useNetworkProxy === true && - initialChunk && - this.networkProxies.length > 0 - ) { - return this.forwardToNetworkProxy( - connectionId, - socket, - connectionRecord, - domainConfig, - initialChunk, - serverName - ); - } - } 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 for browser connection management - if (serverName) { - connectionRecord.lockedDomain = serverName; - } - - // If we didn't forward to NetworkProxy, proceed with 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.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; - // Try to extract SNI - let serverName = ''; - + // Check if this looks like a TLS handshake if (isTlsHandshake(chunk)) { connectionRecord.isTLS = true; + + // Forward directly to NetworkProxy without SNI processing + 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 timeout (${this.settings.initialDataTimeout}ms) for connection from ${remoteIP} on port ${localPort}` + ); + if (connectionRecord.incomingTerminationReason === null) { + connectionRecord.incomingTerminationReason = 'initial_timeout'; + this.incrementTerminationStat('incoming', 'initial_timeout'); + } + socket.end(); + this.cleanupConnection(connectionRecord, 'initial_timeout'); + } + }, 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 && isTlsHandshake(chunk)) { + connectionRecord.isTLS = true; if (this.settings.enableTlsDebugLogging) { console.log( - `[${connectionId}] Extracting SNI from TLS handshake, ${chunk.length} bytes` + `[${connectionId}] TLS handshake detected from ${remoteIP}, ${chunk.length} bytes` + ); + // Try to extract SNI and log detailed debug info + extractSNI(chunk, 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 && 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; + + // 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) => { + if (initialTimeout) { + clearTimeout(initialTimeout); + initialTimeout = null; + } + + initialDataReceived = true; + + // Try to extract SNI + let serverName = ''; + + if (isTlsHandshake(chunk)) { + connectionRecord.isTLS = true; + + if (this.settings.enableTlsDebugLogging) { + console.log( + `[${connectionId}] Extracting SNI from TLS handshake, ${chunk.length} bytes` + ); + } + + serverName = extractSNI(chunk, this.settings.enableTlsDebugLogging) || ''; + } + + // 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)' + }` ); } - serverName = extractSNI(chunk, this.settings.enableTlsDebugLogging) || ''; - } + setupConnection(serverName, chunk); + }); + } else { + initialDataReceived = true; + connectionRecord.hasReceivedInitialData = true; - // 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)' - }` + 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(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(''); } - - setupConnection(''); } }; @@ -1788,12 +1771,11 @@ export class PortProxy { 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 ? ' (SNI passthrough enabled)' : '' - }${this.networkProxies.length > 0 ? ' (NetworkProxy integration enabled)' : ''}${ - this.settings.browserFriendlyMode ? ' (Browser-friendly mode enabled)' : '' - }` + this.settings.sniEnabled && !isNetworkProxyPort ? ' (SNI passthrough enabled)' : '' + }${isNetworkProxyPort ? ' (NetworkProxy forwarding enabled)' : ''}` ); }); this.netServers.push(server); @@ -1963,21 +1945,6 @@ export class PortProxy { } } - /** - * Add or replace NetworkProxy instances - */ - public setNetworkProxies(networkProxies: NetworkProxy[]): void { - this.networkProxies = networkProxies; - console.log(`Updated NetworkProxy instances: ${this.networkProxies.length} proxies configured`); - } - - /** - * Get a list of configured NetworkProxy instances - */ - public getNetworkProxies(): NetworkProxy[] { - return this.networkProxies; - } - /** * Gracefully shut down the proxy */ @@ -2069,6 +2036,16 @@ export class PortProxy { } } + // Stop NetworkProxy if it was started + if (this.networkProxy) { + try { + await this.networkProxy.stop(); + console.log('NetworkProxy stopped successfully'); + } catch (err) { + console.log(`Error stopping NetworkProxy: ${err}`); + } + } + // Clear all tracking maps this.connectionRecords.clear(); this.domainTargetIndices.clear(); @@ -2084,4 +2061,4 @@ export class PortProxy { console.log('PortProxy shutdown complete.'); } -} +} \ No newline at end of file diff --git a/ts/classes.router.ts b/ts/classes.router.ts index efafd0c..74374c6 100644 --- a/ts/classes.router.ts +++ b/ts/classes.router.ts @@ -19,6 +19,21 @@ export interface IRouterResult { pathRemainder?: string; } +/** + * Router for HTTP reverse proxy requests + * + * Supports the following domain matching patterns: + * - Exact matches: "example.com" + * - Wildcard subdomains: "*.example.com" (matches any subdomain of example.com) + * - TLD wildcards: "example.*" (matches example.com, example.org, etc.) + * - Complex wildcards: "*.lossless*" (matches any subdomain of any lossless domain) + * - Default fallback: "*" (matches any unmatched domain) + * + * Also supports path pattern matching for each domain: + * - Exact path: "/api/users" + * - Wildcard paths: "/api/*" + * - Path parameters: "/users/:id/profile" + */ export class ProxyRouter { // Store original configs for reference private reverseProxyConfigs: tsclass.network.IReverseProxyConfig[] = []; @@ -98,9 +113,11 @@ export class ProxyRouter { return exactConfig; } - // Try wildcard subdomain + // Try various wildcard patterns if (hostWithoutPort.includes('.')) { const domainParts = hostWithoutPort.split('.'); + + // Try wildcard subdomain (*.example.com) if (domainParts.length > 2) { const wildcardDomain = `*.${domainParts.slice(1).join('.')}`; const wildcardConfig = this.findConfigForHost(wildcardDomain, urlPath); @@ -108,6 +125,23 @@ export class ProxyRouter { return wildcardConfig; } } + + // Try TLD wildcard (example.*) + const baseDomain = domainParts.slice(0, -1).join('.'); + const tldWildcardDomain = `${baseDomain}.*`; + const tldWildcardConfig = this.findConfigForHost(tldWildcardDomain, urlPath); + if (tldWildcardConfig) { + return tldWildcardConfig; + } + + // Try complex wildcard patterns + const wildcardPatterns = this.findWildcardMatches(hostWithoutPort); + for (const pattern of wildcardPatterns) { + const wildcardConfig = this.findConfigForHost(pattern, urlPath); + if (wildcardConfig) { + return wildcardConfig; + } + } } // Fall back to default config if available @@ -120,6 +154,53 @@ export class ProxyRouter { return undefined; } + /** + * Find potential wildcard patterns that could match a given hostname + * Handles complex patterns like "*.lossless*" or other partial matches + * @param hostname The hostname to find wildcard matches for + * @returns Array of potential wildcard patterns that could match + */ + private findWildcardMatches(hostname: string): string[] { + const patterns: string[] = []; + const hostnameParts = hostname.split('.'); + + // Find all configured hostnames that contain wildcards + const wildcardConfigs = this.reverseProxyConfigs.filter( + config => config.hostName.includes('*') + ); + + // Extract unique wildcard patterns + const wildcardPatterns = [...new Set( + wildcardConfigs.map(config => config.hostName.toLowerCase()) + )]; + + // For each wildcard pattern, check if it could match the hostname + // using simplified regex pattern matching + for (const pattern of wildcardPatterns) { + // Skip the default wildcard '*' + if (pattern === '*') continue; + + // Skip already checked patterns (*.domain.com and domain.*) + if (pattern.startsWith('*.') && pattern.indexOf('*', 2) === -1) continue; + if (pattern.endsWith('.*') && pattern.indexOf('*') === pattern.length - 1) continue; + + // Convert wildcard pattern to regex + const regexPattern = pattern + .replace(/\./g, '\\.') // Escape dots + .replace(/\*/g, '.*'); // Convert * to .* for regex + + // Create regex object with case insensitive flag + const regex = new RegExp(`^${regexPattern}$`, 'i'); + + // If hostname matches this complex pattern, add it to the list + if (regex.test(hostname)) { + patterns.push(pattern); + } + } + + return patterns; + } + /** * Find a config for a specific host and path */