fix(security): critical security and stability fixes
This commit is contained in:
297
ts/proxies/smart-proxy/route-orchestrator.ts
Normal file
297
ts/proxies/smart-proxy/route-orchestrator.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user