import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; import { RouteDoc } from '../db/index.js'; import { routePathClasses } from '../../ts_interfaces/data/route-management.js'; import type { IHttpRedirectInfo, ILetsEncryptHttp01ForwardConfig, IRoute, IMergedRoute, IRouteWarning, IRouteMetadata, IRoutePathPolicyBinding, IRouteSourceBinding, IRouteSecurity, TRouteRateLimitExceededPolicy, } from '../../ts_interfaces/data/route-management.js'; import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js'; import { type IHttp3Config, augmentRouteWithHttp3, routeNeedsHttpProtocol } from '../http3/index.js'; import { DomainOwnershipError, resolveDomainOwnership, type IDomainOwnershipUnverified, type IDomainOwnershipZone, } from '../dns/domain-ownership.js'; import type { ReferenceResolver } from './classes.reference-resolver.js'; import { SourcePolicyCompiler } from './classes.source-policy-compiler.js'; import { deriveHttpRedirects } from './helpers.http-redirects.js'; import { buildLetsEncryptHttp01Route, letsEncryptHttp01ManagedRouteKind, type ISpecialForwardResolvedTarget, } from './helpers.special-forwards.js'; export type TVpnClientAllowEntry = string | { clientId: string; domains: string[] }; export interface IRouteMutationResult { success: boolean; message?: string; } /** * Supplies the ownership inputs a route's certificate requirement is checked * against. Injected (rather than importing DnsManager) so route management keeps * its single direction of dependency and stays unit-testable. */ export interface IRouteDomainOwnershipSource { listOwnershipZones: () => Promise; getAuthorityZones: () => string[]; } /** A route hostname whose certificate requirement has no ownership proof. */ export interface IUnverifiedRouteDomain { routeId: string; routeName: string; ownership: IDomainOwnershipUnverified; } /** Hostnames for which a terminating route asks SmartProxy to obtain a certificate. */ export function collectAutoCertificateHostnames(route: IDcRouterRouteConfig): string[] { const tls = route.action?.tls; if (!tls || tls.mode === 'passthrough' || tls.certificate !== 'auto') { return []; } const domains = route.match?.domains; const entries = Array.isArray(domains) ? domains : typeof domains === 'string' ? domains.split(',') : []; const hostnames = new Set(); for (const entry of entries) { const trimmed = typeof entry === 'string' ? entry.trim() : ''; if (trimmed) hostnames.add(trimmed); } return [...hostnames]; } interface IRouteMutationOptions { trustedManagedMutation?: boolean; replaceMetadata?: boolean; replaceRoute?: boolean; } /** * 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(); private domainOwnershipSource?: IRouteDomainOwnershipSource; constructor( private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined, private getHttp3Config?: () => IHttp3Config | undefined, private getVpnClientAccessForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[], private referenceResolver?: ReferenceResolver, private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | Promise, private getRuntimeRoutes?: ( preparedRoutes?: plugins.smartproxy.IRouteConfig[], ) => plugins.smartproxy.IRouteConfig[] | Promise, private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined, private applyInboundProxyPolicies?: (routes: plugins.smartproxy.IRouteConfig[]) => plugins.smartproxy.IRouteConfig[], ) {} /** Expose routes map for reference resolution lookups. */ public getRoutes(): Map { return this.routes; } public getRoute(id: string): IRoute | undefined { return this.routes.get(id); } public setVpnClientAccessResolver( resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[], ): void { this.getVpnClientAccessForRoute = resolver; } /** * Wire the ownership inputs used to gate certificate requirements. Until this * is set, any route asking for an automatic certificate is refused — a missing * wiring must never silently disable the gate. */ public setDomainOwnershipSource(source?: IRouteDomainOwnershipSource): void { this.domainOwnershipSource = source; } // ========================================================================= // Certificate requirement / domain ownership gate // ========================================================================= /** * Refuse a route whose certificate requirement covers a domain we cannot prove * we own. This is the point of attribution: the operator making the change gets * the reason, instead of a DNS-01 order failing hours later and consuming a * per-domain retry budget that no retry could ever satisfy. */ private async assertCertificateRequirementOwnership( route: IDcRouterRouteConfig, operation: string, ): Promise { const hostnames = collectAutoCertificateHostnames(route); if (hostnames.length === 0) { return; } if (!this.domainOwnershipSource) { throw new Error( `${operation} refused: domain ownership verification is unavailable, so a certificate requirement for ` + `${hostnames.join(', ')} cannot be checked. This is a wiring defect, not a configuration problem.`, ); } const zones = await this.domainOwnershipSource.listOwnershipZones(); const authorityZones = this.domainOwnershipSource.getAuthorityZones(); for (const hostname of hostnames) { const ownership = resolveDomainOwnership({ fqdn: hostname, zones, authorityZones }); if (!ownership.verified) { throw new DomainOwnershipError(ownership, operation, 'route-config-manager', { data: { routeName: route.name }, }); } } } /** * Audit already-stored routes for certificate requirements on unverified * domains. * * Deliberately an audit and not a refusal: these routes are already live, and * a route can be serving traffic on a certificate issued while the domain was * still verifiable, or on a static certificate elsewhere in the set. Refusing * them at startup would convert a latent misconfiguration into an immediate * outage, and the incident these checks exist for cost us silence, not serving. * New and updated routes are refused outright; existing ones are surfaced. */ public async auditRouteDomainOwnership(): Promise { if (!this.domainOwnershipSource) { return []; } const auditableRoutes = [...this.routes.values()].filter((storedRoute) => { return storedRoute.enabled && collectAutoCertificateHostnames(storedRoute.route).length > 0; }); if (auditableRoutes.length === 0) { return []; } const zones = await this.domainOwnershipSource.listOwnershipZones(); const authorityZones = this.domainOwnershipSource.getAuthorityZones(); const findings: IUnverifiedRouteDomain[] = []; for (const storedRoute of auditableRoutes) { for (const hostname of collectAutoCertificateHostnames(storedRoute.route)) { const ownership = resolveDomainOwnership({ fqdn: hostname, zones, authorityZones }); if (ownership.verified) continue; findings.push({ routeId: storedRoute.id, routeName: storedRoute.route.name || storedRoute.id, ownership, }); } } return findings; } /** * Run the audit, publish it as route warnings, and log each finding at `error` * so it reaches the ops log stream rather than only the warnings panel. * * Public because a DNS-authority change can make a previously unprovable * certificate requirement provable (or the reverse) without any route changing. */ public async refreshDomainOwnershipWarnings(): Promise { let findings: IUnverifiedRouteDomain[]; try { findings = await this.auditRouteDomainOwnership(); } catch (err: unknown) { logger.log( 'error', `Route domain ownership audit failed: ${(err as Error).message}. ` + 'Certificate requirements on unverified domains may be present and unreported.', ); return; } this.warnings = this.warnings.filter( (warning) => warning.type !== 'unverified-domain-ownership', ); for (const finding of findings) { const message = `Route '${finding.routeName}' (id: ${finding.routeId}) requests an automatic certificate for ` + `'${finding.ownership.fqdn}', whose ownership is unverified (${finding.ownership.reason}): ` + `${finding.ownership.detail}. Certificate provisioning for this domain cannot succeed until that is fixed.`; this.warnings.push({ type: 'unverified-domain-ownership', routeName: finding.routeName, message, }); logger.log('error', message, { routeId: finding.routeId, domain: finding.ownership.fqdn, ownershipReason: finding.ownership.reason, }); } } public async runExclusiveRouteUpdate(fn: () => Promise): Promise { return await this.routeUpdateMutex.runExclusive(fn); } /** * 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.refreshDomainOwnershipWarnings(); 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, systemKey: route.systemKey, createdAt: route.createdAt, updatedAt: route.updatedAt, metadata: route.metadata, }); } return { routes: merged, warnings: [...this.warnings] }; } public getHttpRedirects(): IHttpRedirectInfo[] { return deriveHttpRedirects(this.getPreparedEnabledRoutesForApply()); } // ========================================================================= // Route CRUD // ========================================================================= public async createRoute( route: IDcRouterRouteConfig, createdBy: string, enabled = true, metadata?: IRouteMetadata, options: IRouteMutationOptions = {}, ): Promise { if (!options.trustedManagedMutation && this.hasReservedManagedMetadata(metadata)) { throw new Error( 'Managed route ownership metadata is reserved; use a specialized route endpoint', ); } metadata = options.trustedManagedMutation ? metadata : { ...metadata, ownerType: 'operator' }; const id = plugins.uuid.v4(); const now = Date.now(); const sourceBindingsPayloadError = SourcePolicyCompiler.validateSourceBindingsPayload(metadata?.sourceBindings); if (sourceBindingsPayloadError) { throw new Error(sourceBindingsPayloadError); } // 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 = this.normalizeRouteMetadata(metadata); if (resolvedMetadata && this.referenceResolver) { const resolved = this.referenceResolver.resolveRoute(route, resolvedMetadata); route = resolved.route; resolvedMetadata = this.normalizeRouteMetadata(resolved.metadata); } const sourceBindingsValidationError = this.validateSourceBindings(resolvedMetadata?.sourceBindings, route); if (sourceBindingsValidationError) { throw new Error(sourceBindingsValidationError); } // Nothing is persisted or applied until the certificate requirement is proven // to cover only domains we own. A disabled route provisions nothing, so only // routes that will actually be applied are gated. if (enabled) { await this.assertCertificateRequirementOwnership(route, 'Route creation'); } 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 createManagedRoute( route: IDcRouterRouteConfig, createdBy: string, enabled: boolean, metadata: IRouteMetadata, ): Promise { if (!this.isManagedMetadata(metadata)) { throw new Error('Managed route metadata is required'); } return await this.createRoute(route, createdBy, enabled, metadata, { trustedManagedMutation: true, }); } public async createLetsEncryptHttp01Forward( config: ILetsEncryptHttp01ForwardConfig, createdBy: string, enabled = true, ): Promise { const compiled = this.compileLetsEncryptHttp01Forward(config); return await this.createManagedRoute( compiled.route, createdBy, enabled, compiled.metadata, ); } public async updateRoute( id: string, patch: { route?: Partial; enabled?: boolean; metadata?: Partial; }, options: IRouteMutationOptions = {}, ): Promise { const stored = this.routes.get(id); if (!stored) { return { success: false, message: 'Route not found' }; } const isToggleOnlyPatch = patch.enabled !== undefined && patch.route === undefined && patch.metadata === undefined; if (!options.trustedManagedMutation && this.hasReservedManagedMetadata(patch.metadata)) { return { success: false, message: 'Managed route ownership metadata is reserved; use a specialized route endpoint', }; } if (!options.trustedManagedMutation && this.isManagedRoute(stored) && !isToggleOnlyPatch) { return { success: false, message: 'Managed routes must be changed through their specialized endpoint', }; } const sourceBindingsPayloadError = SourcePolicyCompiler.validateSourceBindingsPayload(patch.metadata?.sourceBindings); if (sourceBindingsPayloadError) { return { success: false, message: sourceBindingsPayloadError }; } const previousRoute = structuredClone(stored.route); const previousMetadata = structuredClone(stored.metadata); const previousEnabled = stored.enabled; if (stored.origin !== 'api' && !isToggleOnlyPatch) { return { success: false, message: 'System routes are managed by the system and can only be toggled', }; } if (patch.route) { if (options.replaceRoute) { stored.route = structuredClone(patch.route) as IDcRouterRouteConfig; } else { 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]; } } } const mergedRoute = { ...stored.route, ...patch.route, action: mergedAction } as IDcRouterRouteConfig; // Handle explicit null to remove optional top-level route properties (e.g., remoteIngress: null) for (const [key, val] of Object.entries(patch.route)) { if (val === null && key !== 'action' && key !== 'match') { delete (mergedRoute as any)[key]; } } stored.route = mergedRoute; } } if (patch.enabled !== undefined) { stored.enabled = patch.enabled; } if (patch.metadata !== undefined) { stored.metadata = this.normalizeRouteMetadata( options.replaceMetadata ? patch.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 = this.normalizeRouteMetadata(resolved.metadata); } const sourceBindingsValidationError = this.validateSourceBindings(stored.metadata?.sourceBindings, stored.route); if (sourceBindingsValidationError) { stored.route = previousRoute; stored.metadata = previousMetadata; stored.enabled = previousEnabled; return { success: false, message: sourceBindingsValidationError }; } // An update can introduce a certificate requirement, add a hostname to an // existing one, or re-enable a route that carries one — all of them must // clear the ownership gate before anything is persisted or applied. // Disabling is never gated: an operator must always be able to turn a route // off, including the ones this gate is complaining about. if (stored.enabled) { try { await this.assertCertificateRequirementOwnership(stored.route, 'Route update'); } catch (err: unknown) { stored.route = previousRoute; stored.metadata = previousMetadata; stored.enabled = previousEnabled; return { success: false, message: (err as Error).message }; } } stored.updatedAt = Date.now(); await this.persistRoute(stored); await this.applyRoutes(); await this.refreshDomainOwnershipWarnings(); return { success: true }; } public async updateManagedRoute( id: string, patch: { route?: Partial; enabled?: boolean; metadata?: Partial; }, options: Pick = {}, ): Promise { const stored = this.routes.get(id); if (!stored || !this.isManagedRoute(stored)) { return { success: false, message: 'Managed route not found' }; } return await this.updateRoute(id, patch, { trustedManagedMutation: true, ...options, }); } public async updateLetsEncryptHttp01Forward( id: string, config: ILetsEncryptHttp01ForwardConfig, enabled?: boolean, ): Promise { const stored = this.routes.get(id); if ( !stored || stored.metadata?.ownerType === 'gatewayClient' || stored.metadata?.managedRouteKind !== letsEncryptHttp01ManagedRouteKind ) { return { success: false, message: 'Let\'s Encrypt HTTP-01 forward not found' }; } const compiled = this.compileLetsEncryptHttp01Forward(config); return await this.updateManagedRoute( id, { route: compiled.route, metadata: compiled.metadata, ...(enabled !== undefined ? { enabled } : {}), }, { replaceMetadata: true, replaceRoute: true, }, ); } public async deleteRoute( id: string, options: IRouteMutationOptions = {}, ): Promise { const stored = this.routes.get(id); if (!stored) { return { success: false, message: 'Route not found' }; } if (!options.trustedManagedMutation && this.isManagedRoute(stored)) { return { success: false, message: 'Managed routes must be deleted through their specialized endpoint', }; } if (stored.origin !== 'api') { return { success: false, message: 'System routes are managed by the system and cannot be deleted', }; } this.routes.delete(id); const doc = await RouteDoc.findById(id); if (doc) await doc.delete(); await this.applyRoutes(); return { success: true }; } public async deleteManagedRoute(id: string): Promise { const stored = this.routes.get(id); if (!stored || !this.isManagedRoute(stored)) { return { success: false, message: 'Managed route not found' }; } return await this.deleteRoute(id, { trustedManagedMutation: true }); } public async deleteLetsEncryptHttp01Forward(id: string): Promise { const stored = this.routes.get(id); if ( !stored || stored.metadata?.ownerType === 'gatewayClient' || stored.metadata?.managedRouteKind !== letsEncryptHttp01ManagedRouteKind ) { return { success: false, message: 'Let\'s Encrypt HTTP-01 forward not found' }; } return await this.deleteManagedRoute(id); } public async toggleRoute(id: string, enabled: boolean): Promise { return this.updateRoute(id, { enabled }); } public findApiRouteByExternalKey(externalKey: string): IRoute | undefined { for (const route of this.routes.values()) { if (route.origin === 'api' && route.metadata?.externalKey === externalKey) { return route; } } return undefined; } // ========================================================================= // 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 { const seedSystemKeys = new Set(); const seedNames = new Set(); let seeded = 0; let updated = 0; for (const route of seedRoutes) { const name = route.name || ''; if (name) { seedNames.add(name); } const systemKey = this.buildSystemRouteKey(origin, route); if (systemKey) { seedSystemKeys.add(systemKey); } const existingId = this.findExistingSeedRouteId(origin, route, systemKey); if (existingId) { // Update route config but preserve enabled state const existing = this.routes.get(existingId)!; existing.route = route; existing.systemKey = systemKey; 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, systemKey, }; 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) continue; const routeName = r.route.name || ''; const matchesSeedSystemKey = r.systemKey ? seedSystemKeys.has(r.systemKey) : false; const matchesSeedName = routeName ? seedNames.has(routeName) : false; if (!matchesSeedSystemKey && !matchesSeedName) { 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 buildSystemRouteKey( origin: 'config' | 'email' | 'dns', route: IDcRouterRouteConfig, ): string | undefined { const name = route.name?.trim(); if (!name) return undefined; return `${origin}:${name}`; } private findExistingSeedRouteId( origin: 'config' | 'email' | 'dns', route: IDcRouterRouteConfig, systemKey?: string, ): string | undefined { const routeName = route.name || ''; for (const [id, storedRoute] of this.routes) { if (storedRoute.origin !== origin) continue; if (systemKey && storedRoute.systemKey === systemKey) { return id; } if (storedRoute.route.name === routeName) { return id; } } return undefined; } private async loadRoutes(): Promise { const docs = await RouteDoc.findAll(); 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', systemKey: doc.systemKey, metadata: this.normalizeRouteMetadata(doc.metadata), }; this.routes.set(doc.id, storedRoute); } if (this.routes.size > 0) { logger.log('info', `Loaded ${this.routes.size} route(s) from database`); } } 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.systemKey = stored.systemKey; 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.systemKey = stored.systemKey; doc.metadata = stored.metadata; await doc.save(); } } private compileLetsEncryptHttp01Forward( config: ILetsEncryptHttp01ForwardConfig, ): { route: IDcRouterRouteConfig; metadata: IRouteMetadata } { if (!this.referenceResolver) { throw new Error('Reference resolver is unavailable'); } const publicProfile = this.referenceResolver.listProfiles().find((profile) => { return profile.name.trim().toUpperCase() === 'PUBLIC'; }); if (!publicProfile) { throw new Error('The PUBLIC source profile is required for HTTP-01 forwards'); } const publicSecurity = this.referenceResolver.resolveSourceProfileSecurity(publicProfile.id); if (!publicSecurity?.ipAllowList?.includes('*')) { throw new Error('The PUBLIC source profile must allow all source addresses'); } const networkTargetRef = config.networkTargetRef?.trim(); if (networkTargetRef && config.target) { throw new Error('Choose either an inline target or a Network Target, not both'); } let target: ISpecialForwardResolvedTarget; let networkTargetName: string | undefined; if (networkTargetRef) { const networkTarget = this.referenceResolver.getTarget(networkTargetRef); if (!networkTarget) { throw new Error(`Network Target '${networkTargetRef}' was not found`); } target = { host: networkTarget.host, port: networkTarget.port, }; networkTargetName = networkTarget.name; } else if (config.target) { target = config.target; } else { throw new Error('An inline target or Network Target is required'); } return { route: buildLetsEncryptHttp01Route(config, target), metadata: { ownerType: 'operator', managedRouteKind: letsEncryptHttp01ManagedRouteKind, sourceBindings: [{ sourceProfileRef: publicProfile.id, sourceProfileName: publicProfile.name, }], ...(networkTargetRef ? { networkTargetRef, networkTargetName, lastResolvedAt: Date.now(), } : {}), }, }; } private hasReservedManagedMetadata(metadata?: Partial): boolean { return Boolean( metadata && ( metadata.ownerType !== undefined || metadata.gatewayClientType !== undefined || metadata.gatewayClientId !== undefined || metadata.gatewayClientAppId !== undefined || metadata.externalKey !== undefined || metadata.managedRouteKind !== undefined ), ); } private isManagedMetadata(metadata?: Partial): boolean { return Boolean( metadata && ( metadata.ownerType === 'gatewayClient' || metadata.managedRouteKind !== undefined || metadata.gatewayClientId || metadata.externalKey ), ); } private isManagedRoute(storedRoute: IRoute): boolean { return storedRoute.origin === 'api' && this.isManagedMetadata(storedRoute.metadata); } private normalizeRouteMetadata(metadata?: Partial): IRouteMetadata | undefined { if (!metadata) { return undefined; } const normalizeString = (value?: string): string | undefined => { if (typeof value !== 'string') { return undefined; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; }; const normalized: IRouteMetadata = { sourceBindings: this.normalizeSourceBindings(metadata.sourceBindings), networkTargetRef: normalizeString(metadata.networkTargetRef), networkTargetName: normalizeString(metadata.networkTargetName), lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt) ? metadata.lastResolvedAt : undefined, ownerType: metadata.ownerType === 'gatewayClient' || metadata.ownerType === 'operator' || metadata.ownerType === 'system' ? metadata.ownerType : undefined, gatewayClientType: metadata.gatewayClientType === 'onebox' || metadata.gatewayClientType === 'cloudly' || metadata.gatewayClientType === 'custom' ? metadata.gatewayClientType : undefined, gatewayClientId: normalizeString(metadata.gatewayClientId), gatewayClientAppId: normalizeString(metadata.gatewayClientAppId), externalKey: normalizeString(metadata.externalKey), gatewayDnsMode: metadata.gatewayDnsMode === 'skip' || metadata.gatewayDnsMode === 'observe' || metadata.gatewayDnsMode === 'reconcile' ? metadata.gatewayDnsMode : undefined, gatewayDnsProxied: typeof metadata.gatewayDnsProxied === 'boolean' ? metadata.gatewayDnsProxied : undefined, managedRouteKind: metadata.managedRouteKind === letsEncryptHttp01ManagedRouteKind ? metadata.managedRouteKind : undefined, }; if (!normalized.networkTargetRef) { normalized.networkTargetName = undefined; } if (!normalized.sourceBindings && !normalized.networkTargetRef) { normalized.lastResolvedAt = undefined; } if (normalized.ownerType !== 'gatewayClient') { normalized.gatewayClientType = undefined; normalized.gatewayClientId = undefined; normalized.gatewayClientAppId = undefined; normalized.externalKey = undefined; } for (const [key, value] of Object.entries(normalized)) { if (value === undefined) { delete (normalized as Record)[key]; } } if (Object.keys(normalized).length === 0) { return undefined; } return normalized; } private normalizeSourceBindings(sourceBindings?: Partial[]): IRouteSourceBinding[] | undefined { if (!Array.isArray(sourceBindings)) { return undefined; } const normalizedBindings: IRouteSourceBinding[] = []; for (const binding of sourceBindings) { const sourceProfileRef = typeof binding.sourceProfileRef === 'string' ? binding.sourceProfileRef.trim() : ''; if (!sourceProfileRef) { continue; } const normalizedRateLimit = this.normalizeRateLimit(binding.rateLimit); const normalizedChallenge = this.normalizeChallenge(binding.challenge); const normalizedPathPolicies = this.normalizePathPolicies(binding.pathPolicies); normalizedBindings.push({ ...(typeof binding.id === 'string' && binding.id.trim() ? { id: binding.id.trim() } : {}), sourceProfileRef, ...(typeof binding.sourceProfileName === 'string' && binding.sourceProfileName.trim() ? { sourceProfileName: binding.sourceProfileName.trim() } : {}), ...(normalizedRateLimit !== undefined ? { rateLimit: normalizedRateLimit } : {}), ...(normalizedChallenge !== undefined ? { challenge: normalizedChallenge } : {}), ...(typeof binding.maxConnections === 'number' && Number.isFinite(binding.maxConnections) && binding.maxConnections >= 0 ? { maxConnections: binding.maxConnections } : {}), ...(binding.onExceeded?.type === '429' ? { onExceeded: { type: '429' as const, ...(typeof binding.onExceeded.errorMessage === 'string' && binding.onExceeded.errorMessage.trim() ? { errorMessage: binding.onExceeded.errorMessage.trim() } : {}), }, } : {}), ...(normalizedPathPolicies ? { pathPolicies: normalizedPathPolicies } : {}), }); } return normalizedBindings.length > 0 ? normalizedBindings : undefined; } private normalizePathPolicies( pathPolicies?: IRoutePathPolicyBinding[], ): IRoutePathPolicyBinding[] | undefined { if (!Array.isArray(pathPolicies)) { return undefined; } const validClasses = new Set(routePathClasses); const normalizedPathPolicies: IRoutePathPolicyBinding[] = []; for (const pathPolicy of pathPolicies) { if (!validClasses.has(pathPolicy.pathClass)) { continue; } const normalizedRateLimit = this.normalizeRateLimit(pathPolicy.rateLimit); const normalizedChallenge = this.normalizeChallenge(pathPolicy.challenge); const pathPatterns = Array.isArray(pathPolicy.pathPatterns) ? [...new Set(pathPolicy.pathPatterns .map((pattern) => typeof pattern === 'string' ? pattern.trim() : '') .filter(Boolean))] : undefined; normalizedPathPolicies.push({ ...(typeof pathPolicy.id === 'string' && pathPolicy.id.trim() ? { id: pathPolicy.id.trim() } : {}), pathClass: pathPolicy.pathClass, ...(pathPatterns?.length ? { pathPatterns } : {}), ...(normalizedRateLimit !== undefined ? { rateLimit: normalizedRateLimit } : {}), ...(normalizedChallenge !== undefined ? { challenge: normalizedChallenge } : {}), ...(typeof pathPolicy.maxConnections === 'number' && Number.isFinite(pathPolicy.maxConnections) && pathPolicy.maxConnections >= 0 ? { maxConnections: pathPolicy.maxConnections } : {}), ...(pathPolicy.onExceeded?.type === '429' ? { onExceeded: { type: '429' as const, ...(typeof pathPolicy.onExceeded.errorMessage === 'string' && pathPolicy.onExceeded.errorMessage.trim() ? { errorMessage: pathPolicy.onExceeded.errorMessage.trim() } : {}), }, } : {}), }); } return normalizedPathPolicies.length > 0 ? normalizedPathPolicies : undefined; } private validateSourceBindings( sourceBindings: IRouteSourceBinding[] | undefined, route: IDcRouterRouteConfig, ): string | undefined { const shapeError = SourcePolicyCompiler.validateSourceBindingsShape(sourceBindings, route); if (shapeError) { return shapeError; } return SourcePolicyCompiler.validateResolvedSourceBindings(sourceBindings, this.referenceResolver); } private normalizeRateLimit(rateLimit?: IRouteSecurity['rateLimit']): IRouteSecurity['rateLimit'] | undefined { if (rateLimit === null) { return null; } if (!rateLimit || typeof rateLimit !== 'object') { return undefined; } if (rateLimit.enabled === false) { return undefined; } const maxRequests = Number(rateLimit.maxRequests); const window = Number(rateLimit.window); if (!Number.isFinite(maxRequests) || maxRequests <= 0 || !Number.isFinite(window) || window <= 0) { return undefined; } const onExceeded = this.normalizeRateLimitExceeded(rateLimit.onExceeded); return { enabled: true, maxRequests, window, keyBy: 'ip', ...(typeof rateLimit.errorMessage === 'string' && rateLimit.errorMessage.trim() ? { errorMessage: rateLimit.errorMessage.trim() } : {}), ...(onExceeded ? { onExceeded } : {}), }; } private normalizeRateLimitExceeded( onExceeded: TRouteRateLimitExceededPolicy | undefined, ): TRouteRateLimitExceededPolicy | undefined { if (!onExceeded || typeof onExceeded !== 'object') { return undefined; } const rawExceeded = onExceeded; if (rawExceeded.type === 'challenge') { const challenge = this.normalizeChallenge(rawExceeded.challenge); if (!challenge || challenge === null) { return undefined; } return { type: 'challenge' as const, challenge, clearanceEffect: rawExceeded.clearanceEffect === 'none' ? 'none' as const : 'bypass-rate-limit' as const, }; } if (rawExceeded.type === '429') { return { type: '429' as const }; } return undefined; } private normalizeChallenge(challenge?: IRouteSecurity['challenge']): IRouteSecurity['challenge'] | undefined { if (challenge === null) { return null; } if (!challenge || typeof challenge !== 'object') { return undefined; } const providerId = typeof challenge.providerId === 'string' ? challenge.providerId.trim() : ''; const challengeType = typeof challenge.challengeType === 'string' ? challenge.challengeType.trim() : ''; if (!providerId || !challengeType) { return undefined; } return { ...structuredClone(challenge), providerId, challengeType, }; } // ========================================================================= // 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 = this.normalizeRouteMetadata(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; let enabledRoutes = this.getPreparedEnabledRoutesForApply(); const runtimeRoutes = await this.getRuntimeRoutes?.(enabledRoutes) || []; for (const route of runtimeRoutes) { enabledRoutes.push(this.prepareRouteForApply(route)); } if (this.applyInboundProxyPolicies) { enabledRoutes = this.applyInboundProxyPolicies(enabledRoutes); } await smartProxy.updateRoutes(enabledRoutes); // Notify listeners (e.g. RemoteIngressManager) of the route set if (this.onRoutesApplied) { await this.onRoutesApplied(enabledRoutes); } logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`); }); } private getPreparedEnabledRoutesForApply(): plugins.smartproxy.IRouteConfig[] { const enabledRoutes: plugins.smartproxy.IRouteConfig[] = []; // Add all enabled routes with HTTP/3, VPN, and source-policy augmentation for (const route of this.routes.values()) { if (route.enabled) { enabledRoutes.push(...this.prepareStoredRoutesForApply(route)); } } return enabledRoutes; } private prepareStoredRoutesForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig[] { if (this.isManagedAccessRoute(storedRoute) && !storedRoute.metadata?.sourceBindings?.length) { return []; } const hydratedRoute = this.hydrateStoredRoute?.(storedRoute); const sourceBoundRoutes = SourcePolicyCompiler.compileRoute( hydratedRoute || storedRoute.route, storedRoute.metadata, this.referenceResolver, storedRoute.id, ); return sourceBoundRoutes.map((route) => this.prepareRouteForApply(route, storedRoute.id)); } private isManagedAccessRoute(storedRoute: IRoute): boolean { return this.isManagedRoute(storedRoute); } private prepareRouteForApply( route: plugins.smartproxy.IRouteConfig, routeId?: string, ): plugins.smartproxy.IRouteConfig { let preparedRoute = route; const http3Config = this.getHttp3Config?.(); if (routeNeedsHttpProtocol(preparedRoute)) { preparedRoute = { ...preparedRoute, match: { ...preparedRoute.match, protocol: 'http', }, }; } 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 dcRoute = route as IDcRouterRouteConfig; const vpnEntries = this.getVpnClientAccessForRoute?.(dcRoute, routeId) || []; if (!dcRoute.vpnOnly && vpnEntries.length === 0) { return route; } const existingVpnSecurity = route.security?.vpn || {}; const mergedAllowedClients = this.mergeVpnClientAllowEntries( existingVpnSecurity.allowedClients || [], vpnEntries, ); return { ...route, security: { ...route.security, vpn: { ...existingVpnSecurity, required: dcRoute.vpnOnly ? true : existingVpnSecurity.required, allowedClients: mergedAllowedClients, }, }, }; } private mergeVpnClientAllowEntries( existingEntries: TVpnClientAllowEntry[], vpnEntries: TVpnClientAllowEntry[], ): TVpnClientAllowEntry[] { const merged: TVpnClientAllowEntry[] = []; const seen = new Set(); for (const entry of [...existingEntries, ...vpnEntries]) { const key = typeof entry === 'string' ? `client:${entry}` : `domain:${entry.clientId}:${[...entry.domains].sort().join(',')}`; if (seen.has(key)) continue; seen.add(key); merged.push(entry); } return merged; } }