import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; import { RouteDoc } from '../db/index.js'; import type { IRoute, IMergedRoute, IRouteWarning, IRouteMetadata, } from '../../ts_interfaces/data/route-management.js'; import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js'; import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js'; import type { ReferenceResolver } from './classes.reference-resolver.js'; /** An IP allow entry: plain IP/CIDR or domain-scoped. */ export type TIpAllowEntry = string | { ip: string; domains: string[] }; /** * Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine * never receives rapid overlapping route updates that can churn UDP/QUIC listeners. */ class RouteUpdateMutex { private locked = false; private queue: Array<() => void> = []; async runExclusive(fn: () => Promise): Promise { await new Promise((resolve) => { if (!this.locked) { this.locked = true; resolve(); } else { this.queue.push(resolve); } }); try { return await fn(); } finally { this.locked = false; const next = this.queue.shift(); if (next) { this.locked = true; next(); } } } } export class RouteConfigManager { private routes = new Map(); private warnings: IRouteWarning[] = []; private routeUpdateMutex = new RouteUpdateMutex(); constructor( private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined, private getHttp3Config?: () => IHttp3Config | undefined, private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[], private referenceResolver?: ReferenceResolver, private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void, private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[], ) {} /** Expose routes map for reference resolution lookups. */ public getRoutes(): Map { return this.routes; } /** * Load persisted routes, seed serializable config/email/dns routes, * compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy. */ public async initialize( configRoutes: IDcRouterRouteConfig[] = [], emailRoutes: IDcRouterRouteConfig[] = [], dnsRoutes: IDcRouterRouteConfig[] = [], ): Promise { await this.loadRoutes(); await this.seedRoutes(configRoutes, 'config'); await this.seedRoutes(emailRoutes, 'email'); await this.seedRoutes(dnsRoutes, 'dns'); this.computeWarnings(); this.logWarnings(); await this.applyRoutes(); } // ========================================================================= // Route listing // ========================================================================= public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } { const merged: IMergedRoute[] = []; for (const route of this.routes.values()) { merged.push({ route: route.route, id: route.id, enabled: route.enabled, origin: route.origin, createdAt: route.createdAt, updatedAt: route.updatedAt, metadata: route.metadata, }); } return { routes: merged, warnings: [...this.warnings] }; } // ========================================================================= // Route CRUD // ========================================================================= public async createRoute( route: IDcRouterRouteConfig, createdBy: string, enabled = true, metadata?: IRouteMetadata, ): Promise { const id = plugins.uuid.v4(); const now = Date.now(); // Ensure route has a name if (!route.name) { route.name = `route-${id.slice(0, 8)}`; } // Resolve references if metadata has refs and resolver is available let resolvedMetadata = metadata; if (metadata && this.referenceResolver) { const resolved = this.referenceResolver.resolveRoute(route, metadata); route = resolved.route; resolvedMetadata = resolved.metadata; } const stored: IRoute = { id, route, enabled, createdAt: now, updatedAt: now, createdBy, origin: 'api', metadata: resolvedMetadata, }; this.routes.set(id, stored); await this.persistRoute(stored); await this.applyRoutes(); return id; } public async updateRoute( id: string, patch: { route?: Partial; enabled?: boolean; metadata?: Partial; }, ): Promise { const stored = this.routes.get(id); if (!stored) return false; if (patch.route) { const mergedAction = patch.route.action ? { ...stored.route.action, ...patch.route.action } : stored.route.action; // Handle explicit null to remove nested action properties (e.g., tls: null) if (patch.route.action) { for (const [key, val] of Object.entries(patch.route.action)) { if (val === null) { delete (mergedAction as any)[key]; } } } stored.route = { ...stored.route, ...patch.route, action: mergedAction } as IDcRouterRouteConfig; } if (patch.enabled !== undefined) { stored.enabled = patch.enabled; } if (patch.metadata !== undefined) { stored.metadata = { ...stored.metadata, ...patch.metadata }; } // Re-resolve if metadata refs exist and resolver is available if (stored.metadata && this.referenceResolver) { const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata); stored.route = resolved.route; stored.metadata = resolved.metadata; } stored.updatedAt = Date.now(); await this.persistRoute(stored); await this.applyRoutes(); return true; } public async deleteRoute(id: string): Promise { if (!this.routes.has(id)) return false; this.routes.delete(id); const doc = await RouteDoc.findById(id); if (doc) await doc.delete(); await this.applyRoutes(); return true; } public async toggleRoute(id: string, enabled: boolean): Promise { return this.updateRoute(id, { enabled }); } // ========================================================================= // Private: seed routes from constructor config // ========================================================================= /** * Upsert seed routes by name+origin. Preserves user's `enabled` state. * Deletes stale DB routes whose origin matches but name is not in the seed set. */ private async seedRoutes( seedRoutes: IDcRouterRouteConfig[], origin: 'config' | 'email' | 'dns', ): Promise { if (seedRoutes.length === 0) return; const seedNames = new Set(); let seeded = 0; let updated = 0; for (const route of seedRoutes) { const name = route.name || ''; seedNames.add(name); // Check if a route with this name+origin already exists in memory let existingId: string | undefined; for (const [id, r] of this.routes) { if (r.origin === origin && r.route.name === name) { existingId = id; break; } } if (existingId) { // Update route config but preserve enabled state const existing = this.routes.get(existingId)!; existing.route = route; existing.updatedAt = Date.now(); await this.persistRoute(existing); updated++; } else { // Insert new seed route const id = plugins.uuid.v4(); const now = Date.now(); const newRoute: IRoute = { id, route, enabled: true, createdAt: now, updatedAt: now, createdBy: 'system', origin, }; this.routes.set(id, newRoute); await this.persistRoute(newRoute); seeded++; } } // Delete stale routes: same origin but name not in current seed set const staleIds: string[] = []; for (const [id, r] of this.routes) { if (r.origin === origin && !seedNames.has(r.route.name || '')) { staleIds.push(id); } } for (const id of staleIds) { this.routes.delete(id); const doc = await RouteDoc.findById(id); if (doc) await doc.delete(); } if (seeded > 0 || updated > 0 || staleIds.length > 0) { logger.log('info', `Seed routes (${origin}): ${seeded} new, ${updated} updated, ${staleIds.length} stale removed`); } } // ========================================================================= // Private: persistence // ========================================================================= private async loadRoutes(): Promise { const docs = await RouteDoc.findAll(); let prunedRuntimeRoutes = 0; for (const doc of docs) { if (!doc.id) continue; const storedRoute: IRoute = { id: doc.id, route: doc.route, enabled: doc.enabled, createdAt: doc.createdAt, updatedAt: doc.updatedAt, createdBy: doc.createdBy, origin: doc.origin || 'api', metadata: doc.metadata, }; if (this.isPersistedRuntimeRoute(storedRoute)) { await doc.delete(); prunedRuntimeRoutes++; logger.log( 'warn', `Removed persisted runtime-only route '${storedRoute.route.name || storedRoute.id}' (${storedRoute.id}) from RouteDoc`, ); continue; } this.routes.set(doc.id, storedRoute); } if (this.routes.size > 0) { logger.log('info', `Loaded ${this.routes.size} route(s) from database`); } if (prunedRuntimeRoutes > 0) { logger.log('info', `Pruned ${prunedRuntimeRoutes} persisted runtime-only route(s) from RouteDoc`); } } private async persistRoute(stored: IRoute): Promise { const existingDoc = await RouteDoc.findById(stored.id); if (existingDoc) { existingDoc.route = stored.route; existingDoc.enabled = stored.enabled; existingDoc.updatedAt = stored.updatedAt; existingDoc.createdBy = stored.createdBy; existingDoc.origin = stored.origin; existingDoc.metadata = stored.metadata; await existingDoc.save(); } else { const doc = new RouteDoc(); doc.id = stored.id; doc.route = stored.route; doc.enabled = stored.enabled; doc.createdAt = stored.createdAt; doc.updatedAt = stored.updatedAt; doc.createdBy = stored.createdBy; doc.origin = stored.origin; doc.metadata = stored.metadata; await doc.save(); } } // ========================================================================= // Private: warnings // ========================================================================= private computeWarnings(): void { this.warnings = []; for (const route of this.routes.values()) { if (!route.enabled) { const name = route.route.name || route.id; this.warnings.push({ type: 'disabled-route', routeName: name, message: `Route '${name}' (id: ${route.id}) is disabled`, }); } } } private logWarnings(): void { for (const w of this.warnings) { logger.log('warn', w.message); } } // ========================================================================= // Re-resolve routes after profile/target changes // ========================================================================= /** * Re-resolve specific routes by ID (after a profile or target is updated). * Persists each route and calls applyRoutes() once at the end. */ public async reResolveRoutes(routeIds: string[]): Promise { if (!this.referenceResolver || routeIds.length === 0) return; for (const routeId of routeIds) { const stored = this.routes.get(routeId); if (!stored?.metadata) continue; const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata); stored.route = resolved.route; stored.metadata = resolved.metadata; stored.updatedAt = Date.now(); await this.persistRoute(stored); } await this.applyRoutes(); logger.log('info', `Re-resolved ${routeIds.length} route(s) after profile/target change`); } // ========================================================================= // Apply routes to SmartProxy // ========================================================================= public async applyRoutes(): Promise { await this.routeUpdateMutex.runExclusive(async () => { const smartProxy = this.getSmartProxy(); if (!smartProxy) return; const enabledRoutes: plugins.smartproxy.IRouteConfig[] = []; // Add all enabled routes with HTTP/3 and VPN augmentation for (const route of this.routes.values()) { if (route.enabled) { enabledRoutes.push(this.prepareRouteForApply(route.route, route.id)); } } const runtimeRoutes = this.getRuntimeRoutes?.() || []; for (const route of runtimeRoutes) { enabledRoutes.push(this.prepareRouteForApply(route)); } await smartProxy.updateRoutes(enabledRoutes); // Notify listeners (e.g. RemoteIngressManager) of the route set if (this.onRoutesApplied) { this.onRoutesApplied(enabledRoutes); } logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`); }); } private prepareRouteForApply( route: plugins.smartproxy.IRouteConfig, routeId?: string, ): plugins.smartproxy.IRouteConfig { let preparedRoute = route; const http3Config = this.getHttp3Config?.(); if (http3Config?.enabled !== false) { preparedRoute = augmentRouteWithHttp3(preparedRoute, { enabled: true, ...http3Config }); } return this.injectVpnSecurity(preparedRoute, routeId); } private injectVpnSecurity( route: plugins.smartproxy.IRouteConfig, routeId?: string, ): plugins.smartproxy.IRouteConfig { const vpnCallback = this.getVpnClientIpsForRoute; if (!vpnCallback) return route; const dcRoute = route as IDcRouterRouteConfig; if (!dcRoute.vpnOnly) return route; const vpnEntries = vpnCallback(dcRoute, routeId); const existingEntries = route.security?.ipAllowList || []; return { ...route, security: { ...route.security, ipAllowList: [...existingEntries, ...vpnEntries], }, }; } private isPersistedRuntimeRoute(storedRoute: IRoute): boolean { const routeName = storedRoute.route.name || ''; const actionType = storedRoute.route.action?.type; return (routeName.startsWith('dns-over-https-') && actionType === 'socket-handler') || (storedRoute.origin === 'dns' && actionType === 'socket-handler'); } }