297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
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<SmartCertManager>;
|
|
verifyChallengeRouteRemoved?: () => Promise<void>;
|
|
} = {}
|
|
): Promise<{
|
|
portUsageMap: Map<number, Set<string>>;
|
|
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<number, Set<string>> {
|
|
const portUsage = new Map<number, Set<string>>();
|
|
|
|
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<number, Set<string>>, newUsage: Map<number, Set<string>>): 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<void> {
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
} |