import * as plugins from '../../plugins.js'; import { logger } from '../../core/utils/logger.js'; // Importing required components import { ConnectionManager } from './connection-manager.js'; import { SecurityManager } from './security-manager.js'; import { TlsManager } from './tls-manager.js'; import { HttpProxyBridge } from './http-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'; // Import mutex for route update synchronization import { Mutex } from './utils/mutex.js'; // Import ACME state manager import { AcmeStateManager } from './acme-state-manager.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 httpProxyBridge: HttpProxyBridge; 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; // Global challenge route tracking private globalChallengeRouteActive: boolean = false; private routeUpdateLock: any = null; // Will be initialized as AsyncMutex private acmeStateManager: AcmeStateManager; // Track port usage across route updates private portUsageMap: Map> = new Map(); /** * 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, httpProxyPort: settingsArg.httpProxyPort || 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.httpProxyBridge = new HttpProxyBridge(this.settings); // Initialize connection handler with route support this.routeConnectionHandler = new RouteConnectionHandler( this.settings, this.connectionManager, this.securityManager, this.tlsManager, this.httpProxyBridge, this.timeoutManager, this.routeManager ); // Initialize port manager this.portManager = new PortManager(this.settings, this.routeConnectionHandler); // Initialize NFTablesManager this.nftablesManager = new NFTablesManager(this.settings); // Initialize route update mutex for synchronization this.routeUpdateLock = new Mutex(); // Initialize ACME state manager this.acmeStateManager = new AcmeStateManager(); } /** * The settings for the SmartProxy */ public settings: ISmartProxyOptions; /** * Helper method to create and configure certificate manager * This ensures consistent setup including the required ACME callback */ private async createCertificateManager( routes: IRouteConfig[], certStore: string = './certs', acmeOptions?: any, initialState?: { challengeRouteActive?: boolean } ): Promise { const certManager = new SmartCertManager(routes, certStore, acmeOptions, initialState); // Always set up the route update callback for ACME challenges certManager.setUpdateRoutesCallback(async (routes) => { await this.updateRoutes(routes); }); // Connect with HttpProxy if available if (this.httpProxyBridge.getHttpProxy()) { certManager.setHttpProxy(this.httpProxyBridge.getHttpProxy()); } // Set the ACME state manager certManager.setAcmeStateManager(this.acmeStateManager); // Pass down the global ACME config if available if (this.settings.acme) { certManager.setGlobalAcmeDefaults(this.settings.acme); } await certManager.initialize(); return certManager; } /** * 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()) { logger.log('info', 'No routes require certificate management', { component: 'certificate-manager' }); 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 }; logger.log('info', `Using top-level ACME configuration with email: ${acmeOptions.email}`, { component: 'certificate-manager' }); } 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 }; logger.log('info', `Using route-level ACME configuration from route '${routeWithAcme.name}' with email: ${acmeOptions.email}`, { component: 'certificate-manager' }); } } // 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' ); } // Use the helper method to create and configure the certificate manager this.certManager = await this.createCertificateManager( this.settings.routes, this.settings.acme?.certificateStore || './certs', acmeOptions ); } /** * 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) { logger.log('warn', "Cannot start SmartProxy while it's in the shutdown process"); return; } // 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) { logger.log('warn', `${allWarnings.length} configuration warnings found`, { count: allWarnings.length }); for (const warning of allWarnings) { logger.log('warn', `${warning}`); } } // Get listening ports from RouteManager const listeningPorts = this.routeManager.getListeningPorts(); // Initialize port usage tracking this.portUsageMap = this.updatePortUsageMap(this.settings.routes); // Log port usage for startup logger.log('info', `SmartProxy starting with ${listeningPorts.length} ports: ${listeningPorts.join(', ')}`, { portCount: listeningPorts.length, ports: listeningPorts, component: 'smart-proxy' }); // 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); } } // Initialize and start HttpProxy if needed - before port binding if (this.settings.useHttpProxy && this.settings.useHttpProxy.length > 0) { await this.httpProxyBridge.initialize(); await this.httpProxyBridge.start(); } // Start port listeners using the PortManager BEFORE initializing certificate manager // This ensures all required ports are bound and ready when adding ACME challenge routes await this.portManager.addPorts(listeningPorts); // Initialize certificate manager AFTER port binding is complete // This ensures the ACME challenge port is already bound and ready when needed await this.initializeCertificateManager(); // Connect certificate manager with HttpProxy if both are available if (this.certManager && this.httpProxyBridge.getHttpProxy()) { this.certManager.setHttpProxy(this.httpProxyBridge.getHttpProxy()); } // Now that ports are listening, provision any required certificates if (this.certManager) { logger.log('info', 'Starting certificate provisioning now that ports are ready', { component: 'certificate-manager' }); await this.certManager.provisionAllCertificates(); } // 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 httpProxyConnections = 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) { httpProxyConnections++; } 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 logger.log('info', 'Connection statistics', { activeConnections: connectionRecords.size, tls: { total: tlsConnections, completed: completedTlsHandshakes, pending: pendingTlsHandshakes }, nonTls: nonTlsConnections, keepAlive: keepAliveConnections, httpProxy: httpProxyConnections, longestRunning: { incoming: plugins.prettyMs(maxIncoming), outgoing: plugins.prettyMs(maxOutgoing) }, terminationStats: { incoming: terminationStats.incoming, outgoing: terminationStats.outgoing }, component: 'connection-manager' }); }, 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() { logger.log('info', 'SmartProxy shutting down...'); this.isShuttingDown = true; this.portManager.setShuttingDown(true); // Stop certificate manager if (this.certManager) { await this.certManager.stop(); logger.log('info', 'Certificate manager stopped'); } // Stop NFTablesManager await this.nftablesManager.stop(); logger.log('info', 'NFTablesManager stopped'); // Stop the connection logger if (this.connectionLogger) { clearInterval(this.connectionLogger); this.connectionLogger = null; } // Stop all port listeners await this.portManager.closeAll(); logger.log('info', 'All servers closed. Cleaning up active connections...'); // Clean up all active connections this.connectionManager.clearConnections(); // Stop HttpProxy await this.httpProxyBridge.stop(); // Clear ACME state manager this.acmeStateManager.clear(); logger.log('info', 'SmartProxy shutdown complete.'); } /** * Updates the domain configurations for the proxy * * Note: This legacy method has been removed. Use updateRoutes instead. */ public async updateDomainConfigs(): Promise { logger.log('warn', 'Method updateDomainConfigs() is deprecated. Use updateRoutes() instead.'); throw new Error('updateDomainConfigs() is deprecated - use updateRoutes() instead'); } /** * Verify the challenge route has been properly removed from routes */ private async verifyChallengeRouteRemoved(): Promise { const maxRetries = 10; const retryDelay = 100; // milliseconds for (let i = 0; i < maxRetries; i++) { // Check if the challenge route is still in the active routes const challengeRouteExists = this.settings.routes.some(r => r.name === 'acme-challenge'); if (!challengeRouteExists) { try { logger.log('info', 'Challenge route successfully removed from routes'); } catch (error) { // Silently handle logging errors console.log('[INFO] Challenge route successfully removed from routes'); } return; } // Wait before retrying await plugins.smartdelay.delayFor(retryDelay); } const error = `Failed to verify challenge route removal after ${maxRetries} attempts`; try { logger.log('error', error); } catch (logError) { // Silently handle logging errors console.log(`[ERROR] ${error}`); } throw new Error(error); } /** * 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 { return this.routeUpdateLock.runExclusive(async () => { try { logger.log('info', `Updating routes (${newRoutes.length} routes)`, { routeCount: newRoutes.length, component: 'route-manager' }); } catch (error) { // Silently handle logging errors console.log(`[INFO] Updating routes (${newRoutes.length} routes)`); } // Track port usage before and after updates const oldPortUsage = this.updatePortUsageMap(this.settings.routes); const newPortUsage = this.updatePortUsageMap(newRoutes); // Get the lists of currently listening ports and new ports needed const currentPorts = new Set(this.portManager.getListeningPorts()); const newPortsSet = new Set(newPortUsage.keys()); // Log the port usage for debugging try { logger.log('debug', `Current listening ports: ${Array.from(currentPorts).join(', ')}`, { ports: Array.from(currentPorts), component: 'smart-proxy' }); logger.log('debug', `Ports needed for new routes: ${Array.from(newPortsSet).join(', ')}`, { ports: Array.from(newPortsSet), component: 'smart-proxy' }); } catch (error) { // Silently handle logging errors console.log(`[DEBUG] Current listening ports: ${Array.from(currentPorts).join(', ')}`); console.log(`[DEBUG] Ports needed for new routes: ${Array.from(newPortsSet).join(', ')}`); } // Find orphaned ports - ports that no longer have any routes const orphanedPorts = this.findOrphanedPorts(oldPortUsage, newPortUsage); // Find new ports that need binding (only ports that we aren't already listening on) const newBindingPorts = Array.from(newPortsSet).filter(p => !currentPorts.has(p)); // Check for ACME challenge port to give it special handling const acmePort = this.settings.acme?.port || 80; const acmePortNeeded = newPortsSet.has(acmePort); const acmePortListed = newBindingPorts.includes(acmePort); if (acmePortNeeded && acmePortListed) { try { logger.log('info', `Adding ACME challenge port ${acmePort} to routes`, { port: acmePort, component: 'smart-proxy' }); } catch (error) { // Silently handle logging errors console.log(`[INFO] Adding ACME challenge port ${acmePort} to routes`); } } // Get existing routes that use NFTables and update them const oldNfTablesRoutes = this.settings.routes.filter( r => r.action.forwardingEngine === 'nftables' ); const newNfTablesRoutes = newRoutes.filter( r => r.action.forwardingEngine === 'nftables' ); // Update existing NFTables routes 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); } } // Add new NFTables routes 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); // Release orphaned ports first to free resources if (orphanedPorts.length > 0) { try { logger.log('info', `Releasing ${orphanedPorts.length} orphaned ports: ${orphanedPorts.join(', ')}`, { ports: orphanedPorts, component: 'smart-proxy' }); } catch (error) { // Silently handle logging errors console.log(`[INFO] Releasing ${orphanedPorts.length} orphaned ports: ${orphanedPorts.join(', ')}`); } await this.portManager.removePorts(orphanedPorts); } // Add new ports if needed if (newBindingPorts.length > 0) { try { logger.log('info', `Binding to ${newBindingPorts.length} new ports: ${newBindingPorts.join(', ')}`, { ports: newBindingPorts, component: 'smart-proxy' }); } catch (error) { // Silently handle logging errors console.log(`[INFO] Binding to ${newBindingPorts.length} new ports: ${newBindingPorts.join(', ')}`); } // Handle port binding with improved error recovery try { await this.portManager.addPorts(newBindingPorts); } catch (error) { // Special handling for port binding errors // This provides better diagnostics for ACME challenge port conflicts if ((error as any).code === 'EADDRINUSE') { const port = (error as any).port || newBindingPorts[0]; const isAcmePort = port === acmePort; if (isAcmePort) { try { logger.log('warn', `Could not bind to ACME challenge port ${port}. It may be in use by another application.`, { port, component: 'smart-proxy' }); } catch (logError) { console.log(`[WARN] Could not bind to ACME challenge port ${port}. It may be in use by another application.`); } // Re-throw with more helpful message throw new Error( `ACME challenge port ${port} is already in use by another application. ` + `Configure a different port in settings.acme.port (e.g., 8080) or free up port ${port}.` ); } } // Re-throw the original error for other cases throw error; } } // Update settings with the new routes this.settings.routes = newRoutes; // Save the new port usage map for future reference this.portUsageMap = newPortUsage; // If HttpProxy is initialized, resync the configurations if (this.httpProxyBridge.getHttpProxy()) { await this.httpProxyBridge.syncRoutesToHttpProxy(newRoutes); } // Update certificate manager with new routes if (this.certManager) { const existingAcmeOptions = this.certManager.getAcmeOptions(); const existingState = this.certManager.getState(); // Store global state before stopping this.globalChallengeRouteActive = existingState.challengeRouteActive; // Only stop the cert manager if absolutely necessary // First check if there's an ACME route on the same port already const acmePort = existingAcmeOptions?.port || 80; const acmePortInUse = newPortUsage.has(acmePort) && newPortUsage.get(acmePort)!.size > 0; try { logger.log('debug', `ACME port ${acmePort} ${acmePortInUse ? 'is' : 'is not'} already in use by other routes`, { port: acmePort, inUse: acmePortInUse, component: 'smart-proxy' }); } catch (error) { // Silently handle logging errors console.log(`[DEBUG] ACME port ${acmePort} ${acmePortInUse ? 'is' : 'is not'} already in use by other routes`); } await this.certManager.stop(); // Verify the challenge route has been properly removed await this.verifyChallengeRouteRemoved(); // Create new certificate manager with preserved state this.certManager = await this.createCertificateManager( newRoutes, './certs', existingAcmeOptions, { challengeRouteActive: this.globalChallengeRouteActive } ); } }); } /** * 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); } /** * Update the port usage map based on the provided routes * * This tracks which ports are used by which routes, allowing us to * detect when a port is no longer needed and can be released. */ private updatePortUsageMap(routes: IRouteConfig[]): Map> { // Reset the usage map const portUsage = new Map>(); for (const route of routes) { // Get the ports for this route const portsConfig = Array.isArray(route.match.ports) ? route.match.ports : [route.match.ports]; // Expand port range objects to individual port numbers const expandedPorts: number[] = []; for (const portConfig of portsConfig) { if (typeof portConfig === 'number') { expandedPorts.push(portConfig); } else if (typeof portConfig === 'object' && 'from' in portConfig && 'to' in portConfig) { // Expand the port range for (let p = portConfig.from; p <= portConfig.to; p++) { expandedPorts.push(p); } } } // Use route name if available, otherwise generate a unique ID const routeName = route.name || `unnamed_${Math.random().toString(36).substring(2, 9)}`; // Add each port to the usage map for (const port of expandedPorts) { if (!portUsage.has(port)) { portUsage.set(port, new Set()); } portUsage.get(port)!.add(routeName); } } // Log port usage for debugging for (const [port, routes] of portUsage.entries()) { try { logger.log('debug', `Port ${port} is used by ${routes.size} routes: ${Array.from(routes).join(', ')}`, { port, routeCount: routes.size, component: 'smart-proxy' }); } catch (error) { // Silently handle logging errors console.log(`[DEBUG] Port ${port} is used by ${routes.size} routes: ${Array.from(routes).join(', ')}`); } } return portUsage; } /** * Find ports that have no routes in the new configuration */ private findOrphanedPorts(oldUsage: Map>, newUsage: Map>): number[] { const orphanedPorts: number[] = []; for (const [port, routes] of oldUsage.entries()) { if (!newUsage.has(port) || newUsage.get(port)!.size === 0) { orphanedPorts.push(port); try { logger.log('info', `Port ${port} no longer has any associated routes, will be released`, { port, component: 'smart-proxy' }); } catch (error) { // Silently handle logging errors console.log(`[INFO] Port ${port} no longer has any associated routes, will be released`); } } } return orphanedPorts; } /** * 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('*')) { logger.log('warn', `Wildcard domains like "${domain}" are not supported for automatic ACME certificates`, { domain, component: 'certificate-manager' }); 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)) { logger.log('warn', `Domain "${domain}" has invalid format for certificate issuance`, { domain, component: 'certificate-manager' }); 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 httpProxyConnections = 0; // Analyze active connections for (const record of connectionRecords.values()) { if (record.isTLS) tlsConnections++; else nonTlsConnections++; if (record.hasKeepAlive) keepAliveConnections++; if (record.usingNetworkProxy) httpProxyConnections++; } return { activeConnections: connectionRecords.size, tlsConnections, nonTlsConnections, keepAliveConnections, httpProxyConnections, 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; } }