import { logger } from '../../core/utils/logger.js'; import type { IRouteConfig } from './models/route-types.js'; import type { ILogger } from '../http-proxy/models/types.js'; import { RouteValidator } from './utils/route-validator.js'; import { Mutex } from './utils/mutex.js'; import type { PortManager } from './port-manager.js'; import type { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js'; import type { HttpProxyBridge } from './http-proxy-bridge.js'; import type { NFTablesManager } from './nftables-manager.js'; import type { SmartCertManager } from './certificate-manager.js'; /** * Orchestrates route updates and coordination between components * Extracted from SmartProxy to reduce class complexity */ export class RouteOrchestrator { private routeUpdateLock: Mutex; private portManager: PortManager; private routeManager: RouteManager; private httpProxyBridge: HttpProxyBridge; private nftablesManager: NFTablesManager; private certManager: SmartCertManager | null = null; private logger: ILogger; constructor( portManager: PortManager, routeManager: RouteManager, httpProxyBridge: HttpProxyBridge, nftablesManager: NFTablesManager, certManager: SmartCertManager | null, logger: ILogger ) { this.portManager = portManager; this.routeManager = routeManager; this.httpProxyBridge = httpProxyBridge; this.nftablesManager = nftablesManager; this.certManager = certManager; this.logger = logger; this.routeUpdateLock = new Mutex(); } /** * Set or update certificate manager reference */ public setCertManager(certManager: SmartCertManager | null): void { this.certManager = certManager; } /** * Get certificate manager reference */ public getCertManager(): SmartCertManager | null { return this.certManager; } /** * Update routes with validation and coordination */ public async updateRoutes( oldRoutes: IRouteConfig[], newRoutes: IRouteConfig[], options: { acmePort?: number; acmeOptions?: any; acmeState?: any; globalChallengeRouteActive?: boolean; createCertificateManager?: ( routes: IRouteConfig[], certStore: string, acmeOptions?: any, initialState?: any ) => Promise; verifyChallengeRouteRemoved?: () => Promise; } = {} ): Promise<{ portUsageMap: Map>; newChallengeRouteActive: boolean; newCertManager?: SmartCertManager; }> { return this.routeUpdateLock.runExclusive(async () => { // Validate route configurations const validation = RouteValidator.validateRoutes(newRoutes); if (!validation.valid) { RouteValidator.logValidationErrors(validation.errors); throw new Error(`Route validation failed: ${validation.errors.size} route(s) have errors`); } // Track port usage before and after updates const oldPortUsage = this.updatePortUsageMap(oldRoutes); 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 this.logger.debug(`Current listening ports: ${Array.from(currentPorts).join(', ')}`); this.logger.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 = options.acmePort || 80; const acmePortNeeded = newPortsSet.has(acmePort); const acmePortListed = newBindingPorts.includes(acmePort); if (acmePortNeeded && acmePortListed) { this.logger.info(`Adding ACME challenge port ${acmePort} to routes`); } // Update NFTables routes await this.updateNfTablesRoutes(oldRoutes, newRoutes); // Update routes in RouteManager this.routeManager.updateRoutes(newRoutes); // Release orphaned ports first to free resources if (orphanedPorts.length > 0) { this.logger.info(`Releasing ${orphanedPorts.length} orphaned ports: ${orphanedPorts.join(', ')}`); await this.portManager.removePorts(orphanedPorts); } // Add new ports if needed if (newBindingPorts.length > 0) { this.logger.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 if ((error as any).code === 'EADDRINUSE') { const port = (error as any).port || newBindingPorts[0]; const isAcmePort = port === acmePort; if (isAcmePort) { this.logger.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; } } // If HttpProxy is initialized, resync the configurations if (this.httpProxyBridge.getHttpProxy()) { await this.httpProxyBridge.syncRoutesToHttpProxy(newRoutes); } // Update certificate manager if needed let newCertManager: SmartCertManager | undefined; let newChallengeRouteActive = options.globalChallengeRouteActive || false; if (this.certManager && options.createCertificateManager) { const existingAcmeOptions = this.certManager.getAcmeOptions(); const existingState = this.certManager.getState(); // Store global state before stopping newChallengeRouteActive = existingState.challengeRouteActive; // Keep certificate manager routes in sync before stopping this.certManager.setRoutes(newRoutes); await this.certManager.stop(); // Verify the challenge route has been properly removed if (options.verifyChallengeRouteRemoved) { await options.verifyChallengeRouteRemoved(); } // Create new certificate manager with preserved state newCertManager = await options.createCertificateManager( newRoutes, './certs', existingAcmeOptions, { challengeRouteActive: newChallengeRouteActive } ); this.certManager = newCertManager; } return { portUsageMap: newPortUsage, newChallengeRouteActive, newCertManager }; }); } /** * Update port usage map based on the provided routes */ public updatePortUsageMap(routes: IRouteConfig[]): 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()) { this.logger.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); } } return orphanedPorts; } /** * Update NFTables routes */ private async updateNfTablesRoutes(oldRoutes: IRouteConfig[], newRoutes: IRouteConfig[]): Promise { // Get existing routes that use NFTables and update them const oldNfTablesRoutes = oldRoutes.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); } } } }