import * as plugins from '../../plugins.js'; // Importing required components import { ConnectionManager } from './connection-manager.js'; import { SecurityManager } from './security-manager.js'; import { TlsManager } from './tls-manager.js'; import { NetworkProxyBridge } from './network-proxy-bridge.js'; import { TimeoutManager } from './timeout-manager.js'; import { PortManager } from './port-manager.js'; import { RouteManager } from './route-manager.js'; import { RouteConnectionHandler } from './route-connection-handler.js'; import { NFTablesManager } from './nftables-manager.js'; // Certificate manager import { SmartCertManager, type ICertStatus } from './certificate-manager.js'; // Import types and utilities import type { ISmartProxyOptions } from './models/interfaces.js'; import type { IRouteConfig } from './models/route-types.js'; /** * SmartProxy - Pure route-based API * * SmartProxy is a unified proxy system that works with routes to define connection handling behavior. * Each route contains matching criteria (ports, domains, etc.) and an action to take (forward, redirect, block). * * Configuration is provided through a set of routes, with each route defining: * - What to match (ports, domains, paths, client IPs) * - What to do with matching traffic (forward, redirect, block) * - How to handle TLS (passthrough, terminate, terminate-and-reencrypt) * - Security settings (IP restrictions, connection limits) * - Advanced options (timeout, headers, etc.) */ export class SmartProxy extends plugins.EventEmitter { // Port manager handles dynamic listener management private portManager: PortManager; private connectionLogger: NodeJS.Timeout | null = null; private isShuttingDown: boolean = false; // Component managers private connectionManager: ConnectionManager; private securityManager: SecurityManager; private tlsManager: TlsManager; private networkProxyBridge: NetworkProxyBridge; private timeoutManager: TimeoutManager; public routeManager: RouteManager; // Made public for route management private routeConnectionHandler: RouteConnectionHandler; private nftablesManager: NFTablesManager; // Certificate manager for ACME and static certificates private certManager: SmartCertManager | null = null; /** * Constructor for SmartProxy * * @param settingsArg Configuration options containing routes and other settings * Routes define how traffic is matched and handled, with each route having: * - match: criteria for matching traffic (ports, domains, paths, IPs) * - action: what to do with matched traffic (forward, redirect, block) * * Example: * ```ts * const proxy = new SmartProxy({ * routes: [ * { * match: { * ports: 443, * domains: ['example.com', '*.example.com'] * }, * action: { * type: 'forward', * target: { host: '10.0.0.1', port: 8443 }, * tls: { mode: 'passthrough' } * } * } * ], * defaults: { * target: { host: 'localhost', port: 8080 }, * security: { ipAllowList: ['*'] } * } * }); * ``` */ constructor(settingsArg: ISmartProxyOptions) { super(); // Set reasonable defaults for all settings this.settings = { ...settingsArg, 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, }; // Normalize ACME options if provided (support both email and accountEmail) if (this.settings.acme) { // Support both 'email' and 'accountEmail' fields if (this.settings.acme.accountEmail && !this.settings.acme.email) { this.settings.acme.email = this.settings.acme.accountEmail; } // Set reasonable defaults for commonly used fields this.settings.acme = { enabled: this.settings.acme.enabled !== false, // Enable by default if acme object exists port: this.settings.acme.port || 80, email: this.settings.acme.email, useProduction: this.settings.acme.useProduction || false, renewThresholdDays: this.settings.acme.renewThresholdDays || 30, autoRenew: this.settings.acme.autoRenew !== false, // Enable by default certificateStore: this.settings.acme.certificateStore || './certs', skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false, renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours || 24, routeForwards: this.settings.acme.routeForwards || [], ...this.settings.acme // Preserve any additional fields }; } // 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 ); // Create the route manager this.routeManager = new RouteManager(this.settings); // Create other required components this.tlsManager = new TlsManager(this.settings); this.networkProxyBridge = new NetworkProxyBridge(this.settings); // Initialize connection handler with route support this.routeConnectionHandler = new RouteConnectionHandler( this.settings, this.connectionManager, this.securityManager, this.tlsManager, this.networkProxyBridge, this.timeoutManager, this.routeManager ); // Initialize port manager this.portManager = new PortManager(this.settings, this.routeConnectionHandler); // Initialize NFTablesManager this.nftablesManager = new NFTablesManager(this.settings); } /** * The settings for the SmartProxy */ public settings: ISmartProxyOptions; /** * Initialize certificate manager */ private async initializeCertificateManager(): Promise { // Extract global ACME options if any routes use auto certificates const autoRoutes = this.settings.routes.filter(r => r.action.tls?.certificate === 'auto' ); if (autoRoutes.length === 0 && !this.hasStaticCertRoutes()) { console.log('No routes require certificate management'); return; } // Prepare ACME options with priority: // 1. Use top-level ACME config if available // 2. Fall back to first auto route's ACME config // 3. Otherwise use undefined let acmeOptions: { email?: string; useProduction?: boolean; port?: number } | undefined; if (this.settings.acme?.email) { // Use top-level ACME config acmeOptions = { email: this.settings.acme.email, useProduction: this.settings.acme.useProduction || false, port: this.settings.acme.port || 80 }; console.log(`Using top-level ACME configuration with email: ${acmeOptions.email}`); } else if (autoRoutes.length > 0) { // Check for route-level ACME config const routeWithAcme = autoRoutes.find(r => r.action.tls?.acme?.email); if (routeWithAcme?.action.tls?.acme) { const routeAcme = routeWithAcme.action.tls.acme; acmeOptions = { email: routeAcme.email, useProduction: routeAcme.useProduction || false, port: routeAcme.challengePort || 80 }; console.log(`Using route-level ACME configuration from route '${routeWithAcme.name}' with email: ${acmeOptions.email}`); } } // Validate we have required configuration if (autoRoutes.length > 0 && !acmeOptions?.email) { throw new Error( 'ACME email is required for automatic certificate provisioning. ' + 'Please provide email in either:\n' + '1. Top-level "acme" configuration\n' + '2. Individual route\'s "tls.acme" configuration' ); } this.certManager = new SmartCertManager( this.settings.routes, this.settings.acme?.certificateStore || './certs', acmeOptions ); // Pass down the global ACME config to the cert manager if (this.settings.acme) { this.certManager.setGlobalAcmeDefaults(this.settings.acme); } // Connect with NetworkProxy if (this.networkProxyBridge.getNetworkProxy()) { this.certManager.setNetworkProxy(this.networkProxyBridge.getNetworkProxy()); } // Set route update callback for ACME challenges this.certManager.setUpdateRoutesCallback(async (routes) => { await this.updateRoutes(routes); }); await this.certManager.initialize(); } /** * Check if we have routes with static certificates */ private hasStaticCertRoutes(): boolean { return this.settings.routes.some(r => r.action.tls?.certificate && r.action.tls.certificate !== 'auto' ); } /** * Start the proxy server with support for both configuration types */ public async start() { // Don't start if already shutting down if (this.isShuttingDown) { console.log("Cannot start SmartProxy while it's shutting down"); return; } // Initialize certificate manager before starting servers await this.initializeCertificateManager(); // Initialize and start NetworkProxy if needed if (this.settings.useNetworkProxy && this.settings.useNetworkProxy.length > 0) { await this.networkProxyBridge.initialize(); // Connect NetworkProxy with certificate manager if (this.certManager) { this.certManager.setNetworkProxy(this.networkProxyBridge.getNetworkProxy()); } await this.networkProxyBridge.start(); } // Validate the route configuration const configWarnings = this.routeManager.validateConfiguration(); // Also validate ACME configuration const acmeWarnings = this.validateAcmeConfiguration(); const allWarnings = [...configWarnings, ...acmeWarnings]; if (allWarnings.length > 0) { console.log("Configuration warnings:"); for (const warning of allWarnings) { console.log(` - ${warning}`); } } // Get listening ports from RouteManager const listeningPorts = this.routeManager.getListeningPorts(); // Provision NFTables rules for routes that use NFTables for (const route of this.settings.routes) { if (route.action.forwardingEngine === 'nftables') { await this.nftablesManager.provisionRoute(route); } } // Start port listeners using the PortManager await this.portManager.addPorts(listeningPorts); // 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(); } } /** * Extract domain configurations from routes for certificate provisioning * * Note: This method has been removed as we now work directly with routes */ /** * Stop the proxy server */ public async stop() { console.log('SmartProxy shutting down...'); this.isShuttingDown = true; this.portManager.setShuttingDown(true); // Stop certificate manager if (this.certManager) { await this.certManager.stop(); console.log('Certificate manager stopped'); } // Stop NFTablesManager await this.nftablesManager.stop(); console.log('NFTablesManager stopped'); // Stop the connection logger if (this.connectionLogger) { clearInterval(this.connectionLogger); this.connectionLogger = null; } // Stop all port listeners await this.portManager.closeAll(); console.log('All servers closed. Cleaning up active connections...'); // Clean up all active connections this.connectionManager.clearConnections(); // Stop NetworkProxy await this.networkProxyBridge.stop(); console.log('SmartProxy shutdown complete.'); } /** * Updates the domain configurations for the proxy * * Note: This legacy method has been removed. Use updateRoutes instead. */ public async updateDomainConfigs(): Promise { console.warn('Method updateDomainConfigs() is deprecated. Use updateRoutes() instead.'); throw new Error('updateDomainConfigs() is deprecated - use updateRoutes() instead'); } /** * Update routes with new configuration * * This method replaces the current route configuration with the provided routes. * It also provisions certificates for routes that require TLS termination and have * `certificate: 'auto'` set in their TLS configuration. * * @param newRoutes Array of route configurations to use * * Example: * ```ts * proxy.updateRoutes([ * { * match: { ports: 443, domains: 'secure.example.com' }, * action: { * type: 'forward', * target: { host: '10.0.0.1', port: 8443 }, * tls: { mode: 'terminate', certificate: 'auto' } * } * } * ]); * ``` */ public async updateRoutes(newRoutes: IRouteConfig[]): Promise { console.log(`Updating routes (${newRoutes.length} routes)`); // Get existing routes that use NFTables const oldNfTablesRoutes = this.settings.routes.filter( r => r.action.forwardingEngine === 'nftables' ); // Get new routes that use NFTables const newNfTablesRoutes = newRoutes.filter( r => r.action.forwardingEngine === 'nftables' ); // Find routes to remove, update, or add for (const oldRoute of oldNfTablesRoutes) { const newRoute = newNfTablesRoutes.find(r => r.name === oldRoute.name); if (!newRoute) { // Route was removed await this.nftablesManager.deprovisionRoute(oldRoute); } else { // Route was updated await this.nftablesManager.updateRoute(oldRoute, newRoute); } } // Find new routes to add for (const newRoute of newNfTablesRoutes) { const oldRoute = oldNfTablesRoutes.find(r => r.name === newRoute.name); if (!oldRoute) { // New route await this.nftablesManager.provisionRoute(newRoute); } } // Update routes in RouteManager this.routeManager.updateRoutes(newRoutes); // Get the new set of required ports const requiredPorts = this.routeManager.getListeningPorts(); // Update port listeners to match the new configuration await this.portManager.updatePorts(requiredPorts); // Update settings with the new routes this.settings.routes = newRoutes; // If NetworkProxy is initialized, resync the configurations if (this.networkProxyBridge.getNetworkProxy()) { await this.networkProxyBridge.syncRoutesToNetworkProxy(newRoutes); } // Update certificate manager with new routes if (this.certManager) { await this.certManager.stop(); this.certManager = new SmartCertManager( newRoutes, './certs', this.certManager.getAcmeOptions() ); if (this.networkProxyBridge.getNetworkProxy()) { this.certManager.setNetworkProxy(this.networkProxyBridge.getNetworkProxy()); } await this.certManager.initialize(); } } /** * Manually provision a certificate for a route */ public async provisionCertificate(routeName: string): Promise { if (!this.certManager) { throw new Error('Certificate manager not initialized'); } const route = this.settings.routes.find(r => r.name === routeName); if (!route) { throw new Error(`Route ${routeName} not found`); } await this.certManager.provisionCertificate(route); } /** * Force renewal of a certificate */ public async renewCertificate(routeName: string): Promise { if (!this.certManager) { throw new Error('Certificate manager not initialized'); } await this.certManager.renewCertificate(routeName); } /** * Get certificate status for a route */ public getCertificateStatus(routeName: string): ICertStatus | undefined { if (!this.certManager) { return undefined; } return this.certManager.getCertificateStatus(routeName); } /** * Validates if a domain name is valid for certificate issuance */ 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; } /** * Add a new listening port without changing the route configuration * * This allows you to add a port listener without updating routes. * Useful for preparing to listen on a port before adding routes for it. * * @param port The port to start listening on * @returns Promise that resolves when the port is listening */ public async addListeningPort(port: number): Promise { return this.portManager.addPort(port); } /** * Stop listening on a specific port without changing the route configuration * * This allows you to stop a port listener without updating routes. * Useful for temporary maintenance or port changes. * * @param port The port to stop listening on * @returns Promise that resolves when the port is closed */ public async removeListeningPort(port: number): Promise { return this.portManager.removePort(port); } /** * Get a list of all ports currently being listened on * * @returns Array of port numbers */ public getListeningPorts(): number[] { return this.portManager.getListeningPorts(); } /** * 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, acmeEnabled: !!this.certManager, port80HandlerPort: this.certManager ? 80 : null, routes: this.routeManager.getListeningPorts().length, listeningPorts: this.portManager.getListeningPorts(), activePorts: this.portManager.getListeningPorts().length }; } /** * Get a list of eligible domains for ACME certificates */ public getEligibleDomainsForCertificates(): string[] { const domains: string[] = []; // Get domains from routes const routes = this.settings.routes || []; for (const route of routes) { if (!route.match.domains) continue; // Skip routes without TLS termination or auto certificates if (route.action.type !== 'forward' || !route.action.tls || route.action.tls.mode === 'passthrough' || route.action.tls.certificate !== 'auto') continue; const routeDomains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; // Skip domains that can't be used with ACME const eligibleDomains = routeDomains.filter(domain => !domain.includes('*') && this.isValidDomain(domain) ); domains.push(...eligibleDomains); } // Legacy mode is no longer supported return domains; } /** * Get NFTables status */ public async getNfTablesStatus(): Promise> { return this.nftablesManager.getStatus(); } /** * Validate ACME configuration */ private validateAcmeConfiguration(): string[] { const warnings: string[] = []; // Check for routes with certificate: 'auto' const autoRoutes = this.settings.routes.filter(r => r.action.tls?.certificate === 'auto' ); if (autoRoutes.length === 0) { return warnings; } // Check if we have ACME email configuration const hasTopLevelEmail = this.settings.acme?.email; const routesWithEmail = autoRoutes.filter(r => r.action.tls?.acme?.email); if (!hasTopLevelEmail && routesWithEmail.length === 0) { warnings.push( 'Routes with certificate: "auto" require ACME email configuration. ' + 'Add email to either top-level "acme" config or individual route\'s "tls.acme" config.' ); } // Check for port 80 availability for challenges if (autoRoutes.length > 0) { const challengePort = this.settings.acme?.port || 80; const portsInUse = this.routeManager.getListeningPorts(); if (!portsInUse.includes(challengePort)) { warnings.push( `Port ${challengePort} is not configured for any routes but is needed for ACME challenges. ` + `Add a route listening on port ${challengePort} or ensure it's accessible for HTTP-01 challenges.` ); } } // Check for mismatched environments if (this.settings.acme?.useProduction) { const stagingRoutes = autoRoutes.filter(r => r.action.tls?.acme?.useProduction === false ); if (stagingRoutes.length > 0) { warnings.push( 'Top-level ACME uses production but some routes use staging. ' + 'Consider aligning environments to avoid certificate issues.' ); } } // Check for wildcard domains with auto certificates for (const route of autoRoutes) { const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; const wildcardDomains = domains.filter(d => d?.includes('*')); if (wildcardDomains.length > 0) { warnings.push( `Route "${route.name}" has wildcard domain(s) ${wildcardDomains.join(', ')} ` + 'with certificate: "auto". Wildcard certificates require DNS-01 challenges, ' + 'which are not currently supported. Use static certificates instead.' ); } } return warnings; } }