837 lines
28 KiB
TypeScript
837 lines
28 KiB
TypeScript
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,
|
|
IRoute,
|
|
IMergedRoute,
|
|
IRouteWarning,
|
|
IRouteMetadata,
|
|
IRoutePathPolicyBinding,
|
|
IRouteSourceBinding,
|
|
IRouteSecurity,
|
|
} 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';
|
|
import { SourcePolicyCompiler } from './classes.source-policy-compiler.js';
|
|
import { deriveHttpRedirects } from './helpers.http-redirects.js';
|
|
|
|
export type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
|
|
|
|
export interface IRouteMutationResult {
|
|
success: boolean;
|
|
message?: 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 getVpnClientAccessForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[],
|
|
private referenceResolver?: ReferenceResolver,
|
|
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | Promise<void>,
|
|
private getRuntimeRoutes?: (preparedRoutes?: plugins.smartproxy.IRouteConfig[]) => plugins.smartproxy.IRouteConfig[],
|
|
private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
|
|
) {}
|
|
|
|
/** Expose routes map for reference resolution lookups. */
|
|
public getRoutes(): Map<string, IRoute> {
|
|
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;
|
|
}
|
|
|
|
public async runExclusiveRouteUpdate<T>(fn: () => Promise<T>): Promise<T> {
|
|
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<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,
|
|
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,
|
|
): Promise<string> {
|
|
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);
|
|
}
|
|
|
|
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<IRouteMutationResult> {
|
|
const stored = this.routes.get(id);
|
|
if (!stored) {
|
|
return { success: false, message: 'Route not found' };
|
|
}
|
|
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;
|
|
|
|
const isToggleOnlyPatch = patch.enabled !== undefined
|
|
&& patch.route === undefined
|
|
&& patch.metadata === undefined;
|
|
if (stored.origin !== 'api' && !isToggleOnlyPatch) {
|
|
return {
|
|
success: false,
|
|
message: 'System routes are managed by the system and can only be toggled',
|
|
};
|
|
}
|
|
|
|
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];
|
|
}
|
|
}
|
|
}
|
|
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({
|
|
...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 };
|
|
}
|
|
|
|
stored.updatedAt = Date.now();
|
|
|
|
await this.persistRoute(stored);
|
|
await this.applyRoutes();
|
|
return { success: true };
|
|
}
|
|
|
|
public async deleteRoute(id: string): Promise<IRouteMutationResult> {
|
|
const stored = this.routes.get(id);
|
|
if (!stored) {
|
|
return { success: false, message: 'Route not found' };
|
|
}
|
|
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 toggleRoute(id: string, enabled: boolean): Promise<IRouteMutationResult> {
|
|
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<void> {
|
|
const seedSystemKeys = new Set<string>();
|
|
const seedNames = new Set<string>();
|
|
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<void> {
|
|
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<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.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 normalizeRouteMetadata(metadata?: Partial<IRouteMetadata>): 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 === 'workhoster' || metadata.ownerType === 'operator' || metadata.ownerType === 'system'
|
|
? metadata.ownerType
|
|
: undefined,
|
|
gatewayClientType: metadata.gatewayClientType === 'onebox' || metadata.gatewayClientType === 'cloudly' || metadata.gatewayClientType === 'custom'
|
|
? metadata.gatewayClientType
|
|
: metadata.workHosterType,
|
|
gatewayClientId: normalizeString(metadata.gatewayClientId || metadata.workHosterId),
|
|
gatewayClientAppId: normalizeString(metadata.gatewayClientAppId || metadata.workAppId),
|
|
workHosterType: metadata.workHosterType === 'onebox' || metadata.workHosterType === 'cloudly' || metadata.workHosterType === 'custom'
|
|
? metadata.workHosterType
|
|
: metadata.gatewayClientType,
|
|
workHosterId: normalizeString(metadata.workHosterId || metadata.gatewayClientId),
|
|
workAppId: normalizeString(metadata.workAppId || metadata.gatewayClientAppId),
|
|
externalKey: normalizeString(metadata.externalKey),
|
|
};
|
|
|
|
if (!normalized.networkTargetRef) {
|
|
normalized.networkTargetName = undefined;
|
|
}
|
|
if (!normalized.sourceBindings && !normalized.networkTargetRef) {
|
|
normalized.lastResolvedAt = undefined;
|
|
}
|
|
if (normalized.ownerType !== 'gatewayClient' && normalized.ownerType !== 'workhoster') {
|
|
normalized.gatewayClientType = undefined;
|
|
normalized.gatewayClientId = undefined;
|
|
normalized.gatewayClientAppId = undefined;
|
|
normalized.workHosterType = undefined;
|
|
normalized.workHosterId = undefined;
|
|
normalized.workAppId = undefined;
|
|
normalized.externalKey = undefined;
|
|
} else {
|
|
normalized.ownerType = 'gatewayClient';
|
|
normalized.workHosterType = normalized.gatewayClientType;
|
|
normalized.workHosterId = normalized.gatewayClientId;
|
|
normalized.workAppId = normalized.gatewayClientAppId;
|
|
}
|
|
|
|
if (Object.values(normalized).every((value) => value === undefined)) {
|
|
return undefined;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
private normalizeSourceBindings(sourceBindings?: Partial<IRouteSourceBinding>[]): 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 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 ? { rateLimit: normalizedRateLimit } : {}),
|
|
...(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<string>(routePathClasses);
|
|
const normalizedPathPolicies: IRoutePathPolicyBinding[] = [];
|
|
for (const pathPolicy of pathPolicies) {
|
|
if (!validClasses.has(pathPolicy.pathClass)) {
|
|
continue;
|
|
}
|
|
|
|
const normalizedRateLimit = this.normalizeRateLimit(pathPolicy.rateLimit);
|
|
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 ? { rateLimit: normalizedRateLimit } : {}),
|
|
...(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 || typeof rateLimit !== 'object') {
|
|
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;
|
|
}
|
|
|
|
return {
|
|
enabled: rateLimit.enabled !== false,
|
|
maxRequests,
|
|
window,
|
|
keyBy: 'ip',
|
|
...(typeof rateLimit.errorMessage === 'string' && rateLimit.errorMessage.trim()
|
|
? { errorMessage: rateLimit.errorMessage.trim() }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
// =========================================================================
|
|
// 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 = 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<void> {
|
|
await this.routeUpdateMutex.runExclusive(async () => {
|
|
const smartProxy = this.getSmartProxy();
|
|
if (!smartProxy) return;
|
|
|
|
const enabledRoutes = this.getPreparedEnabledRoutesForApply();
|
|
|
|
const runtimeRoutes = this.getRuntimeRoutes?.(enabledRoutes) || [];
|
|
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) {
|
|
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 {
|
|
const metadata = storedRoute.metadata;
|
|
if (storedRoute.origin !== 'api' || !metadata) {
|
|
return false;
|
|
}
|
|
return metadata.ownerType === 'gatewayClient'
|
|
|| metadata.ownerType === 'workhoster'
|
|
|| Boolean(metadata.gatewayClientId)
|
|
|| Boolean(metadata.workHosterId)
|
|
|| Boolean(metadata.externalKey);
|
|
}
|
|
|
|
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 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<string>();
|
|
|
|
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;
|
|
}
|
|
}
|