477 lines
15 KiB
TypeScript
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');
|
|
}
|
|
}
|