Files
dcrouter/ts/config/classes.route-config-manager.ts

477 lines
15 KiB
TypeScript

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<T>(fn: () => Promise<T>): Promise<T> {
await new Promise<void>((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<string, IRoute>();
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<string, IRoute> {
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<void> {
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<string> {
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<IDcRouterRouteConfig>;
enabled?: boolean;
metadata?: Partial<IRouteMetadata>;
},
): Promise<boolean> {
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<boolean> {
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<boolean> {
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<void> {
if (seedRoutes.length === 0) return;
const seedNames = new Set<string>();
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<void> {
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<void> {
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<void> {
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<void> {
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');
}
}