feat(routes): unify route storage and management across config, email, dns, and API origins

This commit is contained in:
2026-04-13 17:38:23 +00:00
parent 5fd036eeb6
commit 4aba8cc353
20 changed files with 349 additions and 647 deletions

View File

@@ -1,5 +1,12 @@
# Changelog # Changelog
## 2026-04-13 - 13.16.0 - feat(routes)
unify route storage and management across config, email, dns, and API origins
- Persist config-, email-, and dns-seeded routes in the database alongside API-created routes using a single RouteDoc model with origin tracking
- Remove hardcoded-route override handling in favor of direct route CRUD and toggle operations by route id across the API client, handlers, and web UI
- Add a migration that renames stored route storage, sets migrated routes to origin="api", and drops obsolete route override data
## 2026-04-13 - 13.15.1 - fix(monitoring) ## 2026-04-13 - 13.15.1 - fix(monitoring)
improve domain activity aggregation for multi-domain and wildcard routes improve domain activity aggregation for multi-domain and wildcard routes

View File

@@ -174,62 +174,20 @@ tap.test('Route - should hydrate from IMergedRoute data', async () => {
match: { ports: 443, domains: 'example.com' }, match: { ports: 443, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] }, action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] },
}, },
source: 'programmatic', id: 'route-123',
enabled: true, enabled: true,
overridden: false, origin: 'api',
storedRouteId: 'route-123',
createdAt: 1000, createdAt: 1000,
updatedAt: 2000, updatedAt: 2000,
}); });
expect(route.name).toEqual('test-route'); expect(route.name).toEqual('test-route');
expect(route.source).toEqual('programmatic'); expect(route.id).toEqual('route-123');
expect(route.enabled).toEqual(true); expect(route.enabled).toEqual(true);
expect(route.overridden).toEqual(false); expect(route.origin).toEqual('api');
expect(route.storedRouteId).toEqual('route-123');
expect(route.routeConfig.match.ports).toEqual(443); expect(route.routeConfig.match.ports).toEqual(443);
}); });
tap.test('Route - should throw on update/delete/toggle for hardcoded routes', async () => {
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
const route = new Route(client, {
route: {
name: 'hardcoded-route',
match: { ports: 80 },
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }] },
},
source: 'hardcoded',
enabled: true,
overridden: false,
// No storedRouteId for hardcoded routes
});
let updateError: Error | undefined;
try {
await route.update({ name: 'new-name' });
} catch (e) {
updateError = e as Error;
}
expect(updateError).toBeTruthy();
expect(updateError!.message).toInclude('hardcoded');
let deleteError: Error | undefined;
try {
await route.delete();
} catch (e) {
deleteError = e as Error;
}
expect(deleteError).toBeTruthy();
let toggleError: Error | undefined;
try {
await route.toggle(false);
} catch (e) {
toggleError = e as Error;
}
expect(toggleError).toBeTruthy();
});
// ============================================================================= // =============================================================================
// Certificate resource class // Certificate resource class
// ============================================================================= // =============================================================================

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.15.1', version: '13.16.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -312,8 +312,10 @@ export class DcRouter {
// TypedRouter for API endpoints // TypedRouter for API endpoints
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
// Cached constructor routes (computed once during setupSmartProxy, used by RouteConfigManager) // Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
private constructorRoutes: plugins.smartproxy.IRouteConfig[] = []; private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
private seedDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Environment access // Environment access
private qenv = new plugins.qenv.Qenv('./', '.nogit/'); private qenv = new plugins.qenv.Qenv('./', '.nogit/');
@@ -549,7 +551,6 @@ export class DcRouter {
await this.targetProfileManager.initialize(); await this.targetProfileManager.initialize();
this.routeConfigManager = new RouteConfigManager( this.routeConfigManager = new RouteConfigManager(
() => this.getConstructorRoutes(),
() => this.smartProxy, () => this.smartProxy,
() => this.options.http3, () => this.options.http3,
this.options.vpnConfig?.enabled this.options.vpnConfig?.enabled
@@ -564,7 +565,7 @@ export class DcRouter {
} }
: undefined, : undefined,
this.referenceResolver, this.referenceResolver,
// Sync merged routes to RemoteIngressManager whenever routes change, // Sync routes to RemoteIngressManager whenever routes change,
// then push updated derived ports to the Rust hub binary // then push updated derived ports to the Rust hub binary
(routes) => { (routes) => {
if (this.remoteIngressManager) { if (this.remoteIngressManager) {
@@ -577,7 +578,11 @@ export class DcRouter {
); );
this.apiTokenManager = new ApiTokenManager(); this.apiTokenManager = new ApiTokenManager();
await this.apiTokenManager.initialize(); await this.apiTokenManager.initialize();
await this.routeConfigManager.initialize(); await this.routeConfigManager.initialize(
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
);
// Seed default profiles/targets if DB is empty and seeding is enabled // Seed default profiles/targets if DB is empty and seeding is enabled
const seeder = new DbSeeder(this.referenceResolver); const seeder = new DbSeeder(this.referenceResolver);
@@ -881,31 +886,30 @@ export class DcRouter {
this.smartProxy = undefined; this.smartProxy = undefined;
} }
let routes: plugins.smartproxy.IRouteConfig[] = []; // Assemble seed routes from constructor config — these will be seeded into DB
// by RouteConfigManager.initialize() when the ConfigManagers service starts.
this.seedConfigRoutes = (this.options.smartProxyConfig?.routes || []) as plugins.smartproxy.IRouteConfig[];
logger.log('info', `Found ${this.seedConfigRoutes.length} routes in config`);
// If user provides full SmartProxy config, use its routes. this.seedEmailRoutes = [];
// NOTE: `smartProxyConfig.acme` is now seed-only — consumed by
// AcmeConfigManager on first boot. The live ACME config always comes
// from the DB via `this.acmeConfigManager.getConfig()`.
if (this.options.smartProxyConfig) {
routes = this.options.smartProxyConfig.routes || [];
logger.log('info', `Found ${routes.length} routes in config`);
}
// If email config exists, automatically add email routes
if (this.options.emailConfig) { if (this.options.emailConfig) {
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig); this.seedEmailRoutes = this.generateEmailRoutes(this.options.emailConfig);
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) }); logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
} }
// If DNS is configured, add DNS routes this.seedDnsRoutes = [];
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) { if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
const dnsRoutes = this.generateDnsRoutes(); this.seedDnsRoutes = this.generateDnsRoutes();
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(dnsRoutes) }); logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.seedDnsRoutes) });
routes = [...routes, ...dnsRoutes];
} }
// Combined routes for SmartProxy bootstrap (before DB routes are loaded)
let routes: plugins.smartproxy.IRouteConfig[] = [
...this.seedConfigRoutes,
...this.seedEmailRoutes,
...this.seedDnsRoutes,
];
// Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager. // Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager.
// If no config exists or it's disabled, SmartProxy's own ACME is turned off // If no config exists or it's disabled, SmartProxy's own ACME is turned off
// and dcrouter's SmartAcme / certProvisionFunction are not wired. // and dcrouter's SmartAcme / certProvisionFunction are not wired.
@@ -952,10 +956,6 @@ export class DcRouter {
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration'); logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
} }
// Cache constructor routes for RouteConfigManager (without VPN security baked in —
// applyRoutes() injects VPN security dynamically so it stays current with client changes)
this.constructorRoutes = [...routes];
// If we have routes or need a basic SmartProxy instance, create it // If we have routes or need a basic SmartProxy instance, create it
if (routes.length > 0 || this.options.smartProxyConfig) { if (routes.length > 0 || this.options.smartProxyConfig) {
logger.log('info', 'Setting up SmartProxy with combined configuration'); logger.log('info', 'Setting up SmartProxy with combined configuration');
@@ -1406,14 +1406,6 @@ export class DcRouter {
return names; return names;
} }
/**
* Get the routes derived from constructor config (smartProxy + email + DNS).
* Used by RouteConfigManager as the "hardcoded" base.
*/
public getConstructorRoutes(): plugins.smartproxy.IRouteConfig[] {
return this.constructorRoutes;
}
public async stop() { public async stop() {
logger.log('info', 'Stopping DcRouter services...'); logger.log('info', 'Stopping DcRouter services...');
@@ -1457,17 +1449,16 @@ export class DcRouter {
// Update configuration // Update configuration
this.options.smartProxyConfig = config; this.options.smartProxyConfig = config;
// Update routes on RemoteIngressManager so derived ports stay in sync // Start new SmartProxy with updated configuration (rebuilds seed routes)
if (this.remoteIngressManager && config.routes) {
this.remoteIngressManager.setRoutes(config.routes as any[]);
}
// Start new SmartProxy with updated configuration (will include email routes if configured)
await this.setupSmartProxy(); await this.setupSmartProxy();
// Re-apply programmatic routes and overrides after SmartProxy restart // Re-seed and re-apply all routes after SmartProxy restart
if (this.routeConfigManager) { if (this.routeConfigManager) {
await this.routeConfigManager.initialize(); await this.routeConfigManager.initialize(
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
);
} }
logger.log('info', 'SmartProxy configuration updated'); logger.log('info', 'SmartProxy configuration updated');
@@ -2185,13 +2176,14 @@ export class DcRouter {
this.remoteIngressManager = new RemoteIngressManager(); this.remoteIngressManager = new RemoteIngressManager();
await this.remoteIngressManager.initialize(); await this.remoteIngressManager.initialize();
// Pass current routes so the manager can derive edge ports from remoteIngress-tagged routes // Pass current bootstrap routes so the manager can derive edge ports initially.
const currentRoutes = this.constructorRoutes; // Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
this.remoteIngressManager.setRoutes(currentRoutes as any[]); // will push the complete merged routes here.
const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.seedDnsRoutes];
this.remoteIngressManager.setRoutes(bootstrapRoutes as any[]);
// Race-condition fix: if ConfigManagers finished before us, re-apply routes // If ConfigManagers finished before us, re-apply routes
// so the callback delivers the full merged set (including DB-stored routes) // so the callback delivers the full DB set to our newly-created remoteIngressManager.
// to our newly-created remoteIngressManager.
if (this.routeConfigManager) { if (this.routeConfigManager) {
await this.routeConfigManager.applyRoutes(); await this.routeConfigManager.applyRoutes();
} }
@@ -2278,11 +2270,10 @@ export class DcRouter {
if (!this.targetProfileManager) return [...ips]; if (!this.targetProfileManager) return [...ips];
const routes = (this.options.smartProxyConfig?.routes || []) as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[]; const allRoutes = this.routeConfigManager?.getRoutes() || new Map();
const storedRoutes = this.routeConfigManager?.getStoredRoutes() || new Map();
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec( const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
targetProfileIds, routes, storedRoutes, targetProfileIds, allRoutes,
); );
// Add target IPs directly // Add target IPs directly
@@ -2305,9 +2296,8 @@ export class DcRouter {
await this.vpnManager.start(); await this.vpnManager.start();
// Re-apply routes now that VPN clients are loaded — ensures hardcoded routes // Re-apply routes now that VPN clients are loaded — ensures vpnOnly routes
// get correct profile-based ipAllowLists (not possible during setupSmartProxy since // get correct profile-based ipAllowLists
// VPN server wasn't ready yet)
await this.routeConfigManager?.applyRoutes(); await this.routeConfigManager?.applyRoutes();
} }

View File

@@ -1,11 +1,11 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { SourceProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js'; import { SourceProfileDoc, NetworkTargetDoc, RouteDoc } from '../db/index.js';
import type { import type {
ISourceProfile, ISourceProfile,
INetworkTarget, INetworkTarget,
IRouteMetadata, IRouteMetadata,
IStoredRoute, IRoute,
IRouteSecurity, IRouteSecurity,
} from '../../ts_interfaces/data/route-management.js'; } from '../../ts_interfaces/data/route-management.js';
@@ -81,7 +81,7 @@ export class ReferenceResolver {
public async deleteProfile( public async deleteProfile(
id: string, id: string,
force: boolean, force: boolean,
storedRoutes?: Map<string, IStoredRoute>, storedRoutes?: Map<string, IRoute>,
): Promise<{ success: boolean; message?: string }> { ): Promise<{ success: boolean; message?: string }> {
const profile = this.profiles.get(id); const profile = this.profiles.get(id);
if (!profile) { if (!profile) {
@@ -131,7 +131,7 @@ export class ReferenceResolver {
return [...this.profiles.values()]; return [...this.profiles.values()];
} }
public getProfileUsage(storedRoutes: Map<string, IStoredRoute>): Map<string, Array<{ id: string; routeName: string }>> { public getProfileUsage(storedRoutes: Map<string, IRoute>): Map<string, Array<{ id: string; routeName: string }>> {
const usage = new Map<string, Array<{ id: string; routeName: string }>>(); const usage = new Map<string, Array<{ id: string; routeName: string }>>();
for (const profile of this.profiles.values()) { for (const profile of this.profiles.values()) {
usage.set(profile.id, []); usage.set(profile.id, []);
@@ -147,7 +147,7 @@ export class ReferenceResolver {
public getProfileUsageForId( public getProfileUsageForId(
profileId: string, profileId: string,
storedRoutes: Map<string, IStoredRoute>, storedRoutes: Map<string, IRoute>,
): Array<{ id: string; routeName: string }> { ): Array<{ id: string; routeName: string }> {
const routes: Array<{ id: string; routeName: string }> = []; const routes: Array<{ id: string; routeName: string }> = [];
for (const [routeId, stored] of storedRoutes) { for (const [routeId, stored] of storedRoutes) {
@@ -214,7 +214,7 @@ export class ReferenceResolver {
public async deleteTarget( public async deleteTarget(
id: string, id: string,
force: boolean, force: boolean,
storedRoutes?: Map<string, IStoredRoute>, storedRoutes?: Map<string, IRoute>,
): Promise<{ success: boolean; message?: string }> { ): Promise<{ success: boolean; message?: string }> {
const target = this.targets.get(id); const target = this.targets.get(id);
if (!target) { if (!target) {
@@ -263,7 +263,7 @@ export class ReferenceResolver {
public getTargetUsageForId( public getTargetUsageForId(
targetId: string, targetId: string,
storedRoutes: Map<string, IStoredRoute>, storedRoutes: Map<string, IRoute>,
): Array<{ id: string; routeName: string }> { ): Array<{ id: string; routeName: string }> {
const routes: Array<{ id: string; routeName: string }> = []; const routes: Array<{ id: string; routeName: string }> = [];
for (const [routeId, stored] of storedRoutes) { for (const [routeId, stored] of storedRoutes) {
@@ -334,20 +334,20 @@ export class ReferenceResolver {
// ========================================================================= // =========================================================================
public async findRoutesByProfileRef(profileId: string): Promise<string[]> { public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
const docs = await StoredRouteDoc.findAll(); const docs = await RouteDoc.findAll();
return docs return docs
.filter((doc) => doc.metadata?.sourceProfileRef === profileId) .filter((doc) => doc.metadata?.sourceProfileRef === profileId)
.map((doc) => doc.id); .map((doc) => doc.id);
} }
public async findRoutesByTargetRef(targetId: string): Promise<string[]> { public async findRoutesByTargetRef(targetId: string): Promise<string[]> {
const docs = await StoredRouteDoc.findAll(); const docs = await RouteDoc.findAll();
return docs return docs
.filter((doc) => doc.metadata?.networkTargetRef === targetId) .filter((doc) => doc.metadata?.networkTargetRef === targetId)
.map((doc) => doc.id); .map((doc) => doc.id);
} }
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IStoredRoute>): string[] { public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IRoute>): string[] {
const ids: string[] = []; const ids: string[] = [];
for (const [routeId, stored] of storedRoutes) { for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.sourceProfileRef === profileId) { if (stored.metadata?.sourceProfileRef === profileId) {
@@ -357,7 +357,7 @@ export class ReferenceResolver {
return ids; return ids;
} }
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IStoredRoute>): string[] { public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IRoute>): string[] {
const ids: string[] = []; const ids: string[] = [];
for (const [routeId, stored] of storedRoutes) { for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.networkTargetRef === targetId) { if (stored.metadata?.networkTargetRef === targetId) {
@@ -547,7 +547,7 @@ export class ReferenceResolver {
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> { private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
for (const routeId of routeIds) { for (const routeId of routeIds) {
const doc = await StoredRouteDoc.findById(routeId); const doc = await RouteDoc.findById(routeId);
if (doc?.metadata) { if (doc?.metadata) {
doc.metadata = { doc.metadata = {
...doc.metadata, ...doc.metadata,
@@ -562,7 +562,7 @@ export class ReferenceResolver {
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> { private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
for (const routeId of routeIds) { for (const routeId of routeIds) {
const doc = await StoredRouteDoc.findById(routeId); const doc = await RouteDoc.findById(routeId);
if (doc?.metadata) { if (doc?.metadata) {
doc.metadata = { doc.metadata = {
...doc.metadata, ...doc.metadata,

View File

@@ -1,9 +1,8 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { StoredRouteDoc, RouteOverrideDoc } from '../db/index.js'; import { RouteDoc } from '../db/index.js';
import type { import type {
IStoredRoute, IRoute,
IRouteOverride,
IMergedRoute, IMergedRoute,
IRouteWarning, IRouteWarning,
IRouteMetadata, IRouteMetadata,
@@ -46,13 +45,11 @@ class RouteUpdateMutex {
} }
export class RouteConfigManager { export class RouteConfigManager {
private storedRoutes = new Map<string, IStoredRoute>(); private routes = new Map<string, IRoute>();
private overrides = new Map<string, IRouteOverride>();
private warnings: IRouteWarning[] = []; private warnings: IRouteWarning[] = [];
private routeUpdateMutex = new RouteUpdateMutex(); private routeUpdateMutex = new RouteUpdateMutex();
constructor( constructor(
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined, private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined, private getHttp3Config?: () => IHttp3Config | undefined,
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[], private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
@@ -60,52 +57,44 @@ export class RouteConfigManager {
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void, private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
) {} ) {}
/** Expose stored routes map for reference resolution lookups. */ /** Expose routes map for reference resolution lookups. */
public getStoredRoutes(): Map<string, IStoredRoute> { public getRoutes(): Map<string, IRoute> {
return this.storedRoutes; return this.routes;
} }
/** /**
* Load persisted routes and overrides, compute warnings, apply to SmartProxy. * Load persisted routes, seed config/email/dns routes, compute warnings, apply to SmartProxy.
*/ */
public async initialize(): Promise<void> { public async initialize(
await this.loadStoredRoutes(); configRoutes: IDcRouterRouteConfig[] = [],
await this.loadOverrides(); 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.computeWarnings();
this.logWarnings(); this.logWarnings();
await this.applyRoutes(); await this.applyRoutes();
} }
// ========================================================================= // =========================================================================
// Merged view // Route listing
// ========================================================================= // =========================================================================
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } { public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
const merged: IMergedRoute[] = []; const merged: IMergedRoute[] = [];
// Hardcoded routes for (const route of this.routes.values()) {
for (const route of this.getHardcodedRoutes()) {
const name = route.name || '';
const override = this.overrides.get(name);
merged.push({ merged.push({
route, route: route.route,
source: 'hardcoded', id: route.id,
enabled: override ? override.enabled : true, enabled: route.enabled,
overridden: !!override, origin: route.origin,
}); createdAt: route.createdAt,
} updatedAt: route.updatedAt,
metadata: route.metadata,
// Programmatic routes
for (const stored of this.storedRoutes.values()) {
merged.push({
route: stored.route,
source: 'programmatic',
enabled: stored.enabled,
overridden: false,
storedRouteId: stored.id,
createdAt: stored.createdAt,
updatedAt: stored.updatedAt,
metadata: stored.metadata,
}); });
} }
@@ -113,7 +102,7 @@ export class RouteConfigManager {
} }
// ========================================================================= // =========================================================================
// Programmatic route CRUD // Route CRUD
// ========================================================================= // =========================================================================
public async createRoute( public async createRoute(
@@ -127,7 +116,7 @@ export class RouteConfigManager {
// Ensure route has a name // Ensure route has a name
if (!route.name) { if (!route.name) {
route.name = `programmatic-${id.slice(0, 8)}`; route.name = `route-${id.slice(0, 8)}`;
} }
// Resolve references if metadata has refs and resolver is available // Resolve references if metadata has refs and resolver is available
@@ -138,17 +127,18 @@ export class RouteConfigManager {
resolvedMetadata = resolved.metadata; resolvedMetadata = resolved.metadata;
} }
const stored: IStoredRoute = { const stored: IRoute = {
id, id,
route, route,
enabled, enabled,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
createdBy, createdBy,
origin: 'api',
metadata: resolvedMetadata, metadata: resolvedMetadata,
}; };
this.storedRoutes.set(id, stored); this.routes.set(id, stored);
await this.persistRoute(stored); await this.persistRoute(stored);
await this.applyRoutes(); await this.applyRoutes();
return id; return id;
@@ -162,7 +152,7 @@ export class RouteConfigManager {
metadata?: Partial<IRouteMetadata>; metadata?: Partial<IRouteMetadata>;
}, },
): Promise<boolean> { ): Promise<boolean> {
const stored = this.storedRoutes.get(id); const stored = this.routes.get(id);
if (!stored) return false; if (!stored) return false;
if (patch.route) { if (patch.route) {
@@ -201,9 +191,9 @@ export class RouteConfigManager {
} }
public async deleteRoute(id: string): Promise<boolean> { public async deleteRoute(id: string): Promise<boolean> {
if (!this.storedRoutes.has(id)) return false; if (!this.routes.has(id)) return false;
this.storedRoutes.delete(id); this.routes.delete(id);
const doc = await StoredRouteDoc.findById(id); const doc = await RouteDoc.findById(id);
if (doc) await doc.delete(); if (doc) await doc.delete();
await this.applyRoutes(); await this.applyRoutes();
return true; return true;
@@ -214,103 +204,124 @@ export class RouteConfigManager {
} }
// ========================================================================= // =========================================================================
// Hardcoded route overrides // Private: seed routes from constructor config
// ========================================================================= // =========================================================================
public async setOverride(routeName: string, enabled: boolean, updatedBy: string): Promise<void> { /**
const override: IRouteOverride = { * Upsert seed routes by name+origin. Preserves user's `enabled` state.
routeName, * Deletes stale DB routes whose origin matches but name is not in the seed set.
enabled, */
updatedAt: Date.now(), private async seedRoutes(
updatedBy, seedRoutes: IDcRouterRouteConfig[],
}; origin: 'config' | 'email' | 'dns',
this.overrides.set(routeName, override); ): Promise<void> {
const existingDoc = await RouteOverrideDoc.findByRouteName(routeName); if (seedRoutes.length === 0) return;
if (existingDoc) {
existingDoc.enabled = override.enabled;
existingDoc.updatedAt = override.updatedAt;
existingDoc.updatedBy = override.updatedBy;
await existingDoc.save();
} else {
const doc = new RouteOverrideDoc();
doc.routeName = override.routeName;
doc.enabled = override.enabled;
doc.updatedAt = override.updatedAt;
doc.updatedBy = override.updatedBy;
await doc.save();
}
this.computeWarnings();
await this.applyRoutes();
}
public async removeOverride(routeName: string): Promise<boolean> { const seedNames = new Set<string>();
if (!this.overrides.has(routeName)) return false; let seeded = 0;
this.overrides.delete(routeName); let updated = 0;
const doc = await RouteOverrideDoc.findByRouteName(routeName);
if (doc) await doc.delete(); for (const route of seedRoutes) {
this.computeWarnings(); const name = route.name || '';
await this.applyRoutes(); seedNames.add(name);
return true;
// 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: persistence
// ========================================================================= // =========================================================================
private async loadStoredRoutes(): Promise<void> { private async loadRoutes(): Promise<void> {
const docs = await StoredRouteDoc.findAll(); const docs = await RouteDoc.findAll();
for (const doc of docs) { for (const doc of docs) {
if (doc.id) { if (doc.id) {
this.storedRoutes.set(doc.id, { this.routes.set(doc.id, {
id: doc.id, id: doc.id,
route: doc.route, route: doc.route,
enabled: doc.enabled, enabled: doc.enabled,
createdAt: doc.createdAt, createdAt: doc.createdAt,
updatedAt: doc.updatedAt, updatedAt: doc.updatedAt,
createdBy: doc.createdBy, createdBy: doc.createdBy,
origin: doc.origin || 'api',
metadata: doc.metadata, metadata: doc.metadata,
}); });
} }
} }
if (this.storedRoutes.size > 0) { if (this.routes.size > 0) {
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`); logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
} }
} }
private async loadOverrides(): Promise<void> { private async persistRoute(stored: IRoute): Promise<void> {
const docs = await RouteOverrideDoc.findAll(); const existingDoc = await RouteDoc.findById(stored.id);
for (const doc of docs) {
if (doc.routeName) {
this.overrides.set(doc.routeName, {
routeName: doc.routeName,
enabled: doc.enabled,
updatedAt: doc.updatedAt,
updatedBy: doc.updatedBy,
});
}
}
if (this.overrides.size > 0) {
logger.log('info', `Loaded ${this.overrides.size} route override(s) from storage`);
}
}
private async persistRoute(stored: IStoredRoute): Promise<void> {
const existingDoc = await StoredRouteDoc.findById(stored.id);
if (existingDoc) { if (existingDoc) {
existingDoc.route = stored.route; existingDoc.route = stored.route;
existingDoc.enabled = stored.enabled; existingDoc.enabled = stored.enabled;
existingDoc.updatedAt = stored.updatedAt; existingDoc.updatedAt = stored.updatedAt;
existingDoc.createdBy = stored.createdBy; existingDoc.createdBy = stored.createdBy;
existingDoc.origin = stored.origin;
existingDoc.metadata = stored.metadata; existingDoc.metadata = stored.metadata;
await existingDoc.save(); await existingDoc.save();
} else { } else {
const doc = new StoredRouteDoc(); const doc = new RouteDoc();
doc.id = stored.id; doc.id = stored.id;
doc.route = stored.route; doc.route = stored.route;
doc.enabled = stored.enabled; doc.enabled = stored.enabled;
doc.createdAt = stored.createdAt; doc.createdAt = stored.createdAt;
doc.updatedAt = stored.updatedAt; doc.updatedAt = stored.updatedAt;
doc.createdBy = stored.createdBy; doc.createdBy = stored.createdBy;
doc.origin = stored.origin;
doc.metadata = stored.metadata; doc.metadata = stored.metadata;
await doc.save(); await doc.save();
} }
@@ -322,33 +333,14 @@ export class RouteConfigManager {
private computeWarnings(): void { private computeWarnings(): void {
this.warnings = []; this.warnings = [];
const hardcodedNames = new Set(this.getHardcodedRoutes().map((r) => r.name || ''));
// Check overrides for (const route of this.routes.values()) {
for (const [routeName, override] of this.overrides) { if (!route.enabled) {
if (!hardcodedNames.has(routeName)) { const name = route.route.name || route.id;
this.warnings.push({ this.warnings.push({
type: 'orphaned-override', type: 'disabled-route',
routeName,
message: `Orphaned override for route '${routeName}' — hardcoded route no longer exists`,
});
} else if (!override.enabled) {
this.warnings.push({
type: 'disabled-hardcoded',
routeName,
message: `Route '${routeName}' is disabled via API override`,
});
}
}
// Check disabled programmatic routes
for (const stored of this.storedRoutes.values()) {
if (!stored.enabled) {
const name = stored.route.name || stored.id;
this.warnings.push({
type: 'disabled-programmatic',
routeName: name, routeName: name,
message: `Programmatic route '${name}' (id: ${stored.id}) is disabled`, message: `Route '${name}' (id: ${route.id}) is disabled`,
}); });
} }
} }
@@ -372,7 +364,7 @@ export class RouteConfigManager {
if (!this.referenceResolver || routeIds.length === 0) return; if (!this.referenceResolver || routeIds.length === 0) return;
for (const routeId of routeIds) { for (const routeId of routeIds) {
const stored = this.storedRoutes.get(routeId); const stored = this.routes.get(routeId);
if (!stored?.metadata) continue; if (!stored?.metadata) continue;
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata); const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
@@ -387,7 +379,7 @@ export class RouteConfigManager {
} }
// ========================================================================= // =========================================================================
// Private: apply merged routes to SmartProxy // Apply routes to SmartProxy
// ========================================================================= // =========================================================================
public async applyRoutes(): Promise<void> { public async applyRoutes(): Promise<void> {
@@ -416,35 +408,25 @@ export class RouteConfigManager {
}; };
}; };
// Add enabled hardcoded routes (respecting overrides, with fresh VPN injection) // Add all enabled routes with HTTP/3 and VPN augmentation
for (const route of this.getHardcodedRoutes()) { for (const route of this.routes.values()) {
const name = route.name || ''; if (route.enabled) {
const override = this.overrides.get(name); let r = route.route;
if (override && !override.enabled) {
continue; // Skip disabled hardcoded route
}
enabledRoutes.push(injectVpn(route));
}
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
for (const stored of this.storedRoutes.values()) {
if (stored.enabled) {
let route = stored.route;
if (http3Config?.enabled !== false) { if (http3Config?.enabled !== false) {
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config }); r = augmentRouteWithHttp3(r, { enabled: true, ...http3Config });
} }
enabledRoutes.push(injectVpn(route, stored.id)); enabledRoutes.push(injectVpn(r, route.id));
} }
} }
await smartProxy.updateRoutes(enabledRoutes); await smartProxy.updateRoutes(enabledRoutes);
// Notify listeners (e.g. RemoteIngressManager) of the merged route set // Notify listeners (e.g. RemoteIngressManager) of the route set
if (this.onRoutesApplied) { if (this.onRoutesApplied) {
this.onRoutesApplied(enabledRoutes); this.onRoutesApplied(enabledRoutes);
} }
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`); logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`);
}); });
} }
} }

View File

@@ -3,7 +3,7 @@ import { logger } from '../logger.js';
import { TargetProfileDoc, VpnClientDoc } from '../db/index.js'; import { TargetProfileDoc, VpnClientDoc } from '../db/index.js';
import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.js'; import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.js';
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js'; import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import type { IStoredRoute } from '../../ts_interfaces/data/route-management.js'; import type { IRoute } from '../../ts_interfaces/data/route-management.js';
/** /**
* Manages TargetProfiles (target-side: what can be accessed). * Manages TargetProfiles (target-side: what can be accessed).
@@ -220,8 +220,7 @@ export class TargetProfileManager {
*/ */
public getClientAccessSpec( public getClientAccessSpec(
targetProfileIds: string[], targetProfileIds: string[],
allRoutes: IDcRouterRouteConfig[], allRoutes: Map<string, IRoute>,
storedRoutes: Map<string, IStoredRoute>,
): { domains: string[]; targetIps: string[] } { ): { domains: string[]; targetIps: string[] } {
const domains = new Set<string>(); const domains = new Set<string>();
const targetIps = new Set<string>(); const targetIps = new Set<string>();
@@ -245,23 +244,11 @@ export class TargetProfileManager {
} }
} }
// Route references: scan constructor routes // Route references: scan all routes
for (const route of allRoutes) { for (const [routeId, route] of allRoutes) {
if (this.routeMatchesProfile(route as IDcRouterRouteConfig, undefined, profile)) { if (!route.enabled) continue;
const routeDomains = (route.match as any)?.domains; if (this.routeMatchesProfile(route.route as IDcRouterRouteConfig, routeId, profile)) {
if (Array.isArray(routeDomains)) { const routeDomains = (route.route.match as any)?.domains;
for (const d of routeDomains) {
domains.add(d);
}
}
}
}
// Route references: scan stored routes
for (const [storedId, stored] of storedRoutes) {
if (!stored.enabled) continue;
if (this.routeMatchesProfile(stored.route as IDcRouterRouteConfig, storedId, profile)) {
const routeDomains = (stored.route.match as any)?.domains;
if (Array.isArray(routeDomains)) { if (Array.isArray(routeDomains)) {
for (const d of routeDomains) { for (const d of routeDomains) {
domains.add(d); domains.add(d);

View File

@@ -1,32 +0,0 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class RouteOverrideDoc extends plugins.smartdata.SmartDataDbDoc<RouteOverrideDoc, RouteOverrideDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public routeName!: string;
@plugins.smartdata.svDb()
public enabled!: boolean;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public updatedBy!: string;
constructor() {
super();
}
public static async findByRouteName(routeName: string): Promise<RouteOverrideDoc | null> {
return await RouteOverrideDoc.getInstance({ routeName });
}
public static async findAll(): Promise<RouteOverrideDoc[]> {
return await RouteOverrideDoc.getInstances({});
}
}

View File

@@ -6,7 +6,7 @@ import type { IDcRouterRouteConfig } from '../../../ts_interfaces/data/remoteing
const getDb = () => DcRouterDb.getInstance().getDb(); const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb()) @plugins.smartdata.Collection(() => getDb())
export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRouteDoc, StoredRouteDoc> { export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDoc> {
@plugins.smartdata.unI() @plugins.smartdata.unI()
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public id!: string; public id!: string;
@@ -26,6 +26,9 @@ export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRoute
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public createdBy!: string; public createdBy!: string;
@plugins.smartdata.svDb()
public origin!: 'config' | 'email' | 'dns' | 'api';
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public metadata?: IRouteMetadata; public metadata?: IRouteMetadata;
@@ -33,11 +36,19 @@ export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRoute
super(); super();
} }
public static async findById(id: string): Promise<StoredRouteDoc | null> { public static async findById(id: string): Promise<RouteDoc | null> {
return await StoredRouteDoc.getInstance({ id }); return await RouteDoc.getInstance({ id });
} }
public static async findAll(): Promise<StoredRouteDoc[]> { public static async findAll(): Promise<RouteDoc[]> {
return await StoredRouteDoc.getInstances({}); return await RouteDoc.getInstances({});
}
public static async findByName(name: string): Promise<RouteDoc | null> {
return await RouteDoc.getInstance({ 'route.name': name });
}
public static async findByOrigin(origin: 'config' | 'email' | 'dns' | 'api'): Promise<RouteDoc[]> {
return await RouteDoc.getInstances({ origin });
} }
} }

View File

@@ -3,8 +3,7 @@ export * from './classes.cached.email.js';
export * from './classes.cached.ip.reputation.js'; export * from './classes.cached.ip.reputation.js';
// Config document classes // Config document classes
export * from './classes.stored-route.doc.js'; export * from './classes.route.doc.js';
export * from './classes.route-override.doc.js';
export * from './classes.api-token.doc.js'; export * from './classes.api-token.doc.js';
export * from './classes.source-profile.doc.js'; export * from './classes.source-profile.doc.js';
export * from './classes.target-profile.doc.js'; export * from './classes.target-profile.doc.js';

View File

@@ -135,7 +135,7 @@ export class NetworkTargetHandler {
const result = await resolver.deleteTarget( const result = await resolver.deleteTarget(
dataArg.id, dataArg.id,
dataArg.force ?? false, dataArg.force ?? false,
manager.getStoredRoutes(), manager.getRoutes(),
); );
if (result.success && dataArg.force) { if (result.success && dataArg.force) {
@@ -158,7 +158,7 @@ export class NetworkTargetHandler {
if (!resolver || !manager) { if (!resolver || !manager) {
return { routes: [] }; return { routes: [] };
} }
const usage = resolver.getTargetUsageForId(dataArg.id, manager.getStoredRoutes()); const usage = resolver.getTargetUsageForId(dataArg.id, manager.getRoutes());
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) }; return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
}, },
), ),

View File

@@ -72,7 +72,7 @@ export class RouteManagementHandler {
return { success: false, message: 'Route management not initialized' }; return { success: false, message: 'Route management not initialized' };
} }
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true, dataArg.metadata); const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true, dataArg.metadata);
return { success: true, storedRouteId: id }; return { success: true, routeId: id };
}, },
), ),
); );
@@ -113,39 +113,7 @@ export class RouteManagementHandler {
), ),
); );
// Set override on a hardcoded route // Toggle route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRouteOverride>(
'setRouteOverride',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
await manager.setOverride(dataArg.routeName, dataArg.enabled, userId);
return { success: true };
},
),
);
// Remove override from a hardcoded route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRouteOverride>(
'removeRouteOverride',
async (dataArg) => {
await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.removeOverride(dataArg.routeName);
return { success: ok, message: ok ? undefined : 'Override not found' };
},
),
);
// Toggle programmatic route
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleRoute>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleRoute>(
'toggleRoute', 'toggleRoute',

View File

@@ -136,7 +136,7 @@ export class SourceProfileHandler {
const result = await resolver.deleteProfile( const result = await resolver.deleteProfile(
dataArg.id, dataArg.id,
dataArg.force ?? false, dataArg.force ?? false,
manager.getStoredRoutes(), manager.getRoutes(),
); );
// If force-deleted with affected routes, re-apply // If force-deleted with affected routes, re-apply
@@ -160,7 +160,7 @@ export class SourceProfileHandler {
if (!resolver || !manager) { if (!resolver || !manager) {
return { routes: [] }; return { routes: [] };
} }
const usage = resolver.getProfileUsageForId(dataArg.id, manager.getStoredRoutes()); const usage = resolver.getProfileUsageForId(dataArg.id, manager.getRoutes());
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) }; return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
}, },
), ),

View File

@@ -7,10 +7,9 @@ export class Route {
// Data from IMergedRoute // Data from IMergedRoute
public routeConfig: IRouteConfig; public routeConfig: IRouteConfig;
public source: 'hardcoded' | 'programmatic'; public id: string;
public enabled: boolean; public enabled: boolean;
public overridden: boolean; public origin: 'config' | 'email' | 'dns' | 'api';
public storedRouteId?: string;
public createdAt?: number; public createdAt?: number;
public updatedAt?: number; public updatedAt?: number;
@@ -22,21 +21,17 @@ export class Route {
constructor(clientRef: DcRouterApiClient, data: interfaces.data.IMergedRoute) { constructor(clientRef: DcRouterApiClient, data: interfaces.data.IMergedRoute) {
this.clientRef = clientRef; this.clientRef = clientRef;
this.routeConfig = data.route; this.routeConfig = data.route;
this.source = data.source; this.id = data.id;
this.enabled = data.enabled; this.enabled = data.enabled;
this.overridden = data.overridden; this.origin = data.origin;
this.storedRouteId = data.storedRouteId;
this.createdAt = data.createdAt; this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt; this.updatedAt = data.updatedAt;
} }
public async update(changes: Partial<IRouteConfig>): Promise<void> { public async update(changes: Partial<IRouteConfig>): Promise<void> {
if (!this.storedRouteId) {
throw new Error('Cannot update a hardcoded route. Use setOverride() instead.');
}
const response = await this.clientRef.request<interfaces.requests.IReq_UpdateRoute>( const response = await this.clientRef.request<interfaces.requests.IReq_UpdateRoute>(
'updateRoute', 'updateRoute',
this.clientRef.buildRequestPayload({ id: this.storedRouteId, route: changes }) as any, this.clientRef.buildRequestPayload({ id: this.id, route: changes }) as any,
); );
if (!response.success) { if (!response.success) {
throw new Error(response.message || 'Failed to update route'); throw new Error(response.message || 'Failed to update route');
@@ -44,12 +39,9 @@ export class Route {
} }
public async delete(): Promise<void> { public async delete(): Promise<void> {
if (!this.storedRouteId) {
throw new Error('Cannot delete a hardcoded route. Use setOverride() instead.');
}
const response = await this.clientRef.request<interfaces.requests.IReq_DeleteRoute>( const response = await this.clientRef.request<interfaces.requests.IReq_DeleteRoute>(
'deleteRoute', 'deleteRoute',
this.clientRef.buildRequestPayload({ id: this.storedRouteId }) as any, this.clientRef.buildRequestPayload({ id: this.id }) as any,
); );
if (!response.success) { if (!response.success) {
throw new Error(response.message || 'Failed to delete route'); throw new Error(response.message || 'Failed to delete route');
@@ -57,41 +49,15 @@ export class Route {
} }
public async toggle(enabled: boolean): Promise<void> { public async toggle(enabled: boolean): Promise<void> {
if (!this.storedRouteId) {
throw new Error('Cannot toggle a hardcoded route. Use setOverride() instead.');
}
const response = await this.clientRef.request<interfaces.requests.IReq_ToggleRoute>( const response = await this.clientRef.request<interfaces.requests.IReq_ToggleRoute>(
'toggleRoute', 'toggleRoute',
this.clientRef.buildRequestPayload({ id: this.storedRouteId, enabled }) as any, this.clientRef.buildRequestPayload({ id: this.id, enabled }) as any,
); );
if (!response.success) { if (!response.success) {
throw new Error(response.message || 'Failed to toggle route'); throw new Error(response.message || 'Failed to toggle route');
} }
this.enabled = enabled; this.enabled = enabled;
} }
public async setOverride(enabled: boolean): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_SetRouteOverride>(
'setRouteOverride',
this.clientRef.buildRequestPayload({ routeName: this.name, enabled }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to set route override');
}
this.overridden = true;
this.enabled = enabled;
}
public async removeOverride(): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_RemoveRouteOverride>(
'removeRouteOverride',
this.clientRef.buildRequestPayload({ routeName: this.name }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to remove route override');
}
this.overridden = false;
}
} }
export class RouteBuilder { export class RouteBuilder {
@@ -144,9 +110,8 @@ export class RouteBuilder {
} }
// Return a Route instance by re-fetching the list // Return a Route instance by re-fetching the list
// The created route is programmatic, so we find it by storedRouteId
const { routes } = await new RouteManager(this.clientRef).list(); const { routes } = await new RouteManager(this.clientRef).list();
const created = routes.find((r) => r.storedRouteId === response.storedRouteId); const created = routes.find((r) => r.id === response.routeId);
if (created) { if (created) {
return created; return created;
} }
@@ -154,10 +119,9 @@ export class RouteBuilder {
// Fallback: construct from known data // Fallback: construct from known data
return new Route(this.clientRef, { return new Route(this.clientRef, {
route: this.routeConfig as IRouteConfig, route: this.routeConfig as IRouteConfig,
source: 'programmatic', id: response.routeId || '',
enabled: this.isEnabled, enabled: this.isEnabled,
overridden: false, origin: 'api',
storedRouteId: response.storedRouteId,
}); });
} }
} }
@@ -190,10 +154,9 @@ export class RouteManager {
} }
return new Route(this.clientRef, { return new Route(this.clientRef, {
route: routeConfig, route: routeConfig,
source: 'programmatic', id: response.routeId || '',
enabled: enabled ?? true, enabled: enabled ?? true,
overridden: false, origin: 'api',
storedRouteId: response.storedRouteId,
}); });
} }

View File

@@ -83,24 +83,23 @@ export interface IRouteMetadata {
} }
/** /**
* A merged route combining hardcoded and programmatic sources. * A route entry returned by the route management API.
*/ */
export interface IMergedRoute { export interface IMergedRoute {
route: IDcRouterRouteConfig; route: IDcRouterRouteConfig;
source: 'hardcoded' | 'programmatic'; id: string;
enabled: boolean; enabled: boolean;
overridden: boolean; origin: 'config' | 'email' | 'dns' | 'api';
storedRouteId?: string;
createdAt?: number; createdAt?: number;
updatedAt?: number; updatedAt?: number;
metadata?: IRouteMetadata; metadata?: IRouteMetadata;
} }
/** /**
* A warning generated during route merge/startup. * A warning generated during route startup/apply.
*/ */
export interface IRouteWarning { export interface IRouteWarning {
type: 'disabled-hardcoded' | 'disabled-programmatic' | 'orphaned-override'; type: 'disabled-route';
routeName: string; routeName: string;
message: string; message: string;
} }
@@ -123,28 +122,19 @@ export interface IApiTokenInfo {
// ============================================================================ // ============================================================================
/** /**
* A programmatic route stored in /config-api/routes/{id}.json * A route persisted in the database.
*/ */
export interface IStoredRoute { export interface IRoute {
id: string; id: string;
route: IDcRouterRouteConfig; route: IDcRouterRouteConfig;
enabled: boolean; enabled: boolean;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
createdBy: string; createdBy: string;
origin: 'config' | 'email' | 'dns' | 'api';
metadata?: IRouteMetadata; metadata?: IRouteMetadata;
} }
/**
* An override for a hardcoded route, stored in /config-api/overrides/{routeName}.json
*/
export interface IRouteOverride {
routeName: string;
enabled: boolean;
updatedAt: number;
updatedBy: string;
}
/** /**
* A stored API token, stored in /config-api/tokens/{id}.json * A stored API token, stored in /config-api/tokens/{id}.json
*/ */

View File

@@ -9,7 +9,7 @@ import type { IDcRouterRouteConfig } from '../data/remoteingress.js';
// ============================================================================ // ============================================================================
/** /**
* Get all merged routes (hardcoded + programmatic) with warnings. * Get all routes with warnings.
*/ */
export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
@@ -27,7 +27,7 @@ export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.imp
} }
/** /**
* Create a new programmatic route. * Create a new route.
*/ */
export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
@@ -43,13 +43,13 @@ export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.impleme
}; };
response: { response: {
success: boolean; success: boolean;
storedRouteId?: string; routeId?: string;
message?: string; message?: string;
}; };
} }
/** /**
* Update a programmatic route. * Update a route.
*/ */
export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
@@ -71,7 +71,7 @@ export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.impleme
} }
/** /**
* Delete a programmatic route. * Delete a route.
*/ */
export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
@@ -90,46 +90,7 @@ export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.impleme
} }
/** /**
* Set an override on a hardcoded route (disable/enable by name). * Toggle a route on/off by id.
*/
export interface IReq_SetRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_SetRouteOverride
> {
method: 'setRouteOverride';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
routeName: string;
enabled: boolean;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Remove an override from a hardcoded route (restore default behavior).
*/
export interface IReq_RemoveRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RemoveRouteOverride
> {
method: 'removeRouteOverride';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
routeName: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Toggle a programmatic route on/off by id.
*/ */
export interface IReq_ToggleRoute extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_ToggleRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,

View File

@@ -92,6 +92,34 @@ export async function createMigrationRunner(
'info', 'info',
`rename-record-source-manual-to-local: migrated ${result.modifiedCount} record(s)`, `rename-record-source-manual-to-local: migrated ${result.modifiedCount} record(s)`,
); );
})
.step('unify-routes-rename-collection')
.from('13.8.2').to('13.16.0')
.description('Rename storedroutedoc → routedoc, add origin field, drop routeoverridedoc')
.up(async (ctx) => {
const db = ctx.mongo!;
// 1. Rename storedroutedoc → routedoc
const collections = await db.listCollections({ name: 'storedroutedoc' }).toArray();
if (collections.length > 0) {
await db.renameCollection('storedroutedoc', 'routedoc');
ctx.log.log('info', 'Renamed storedroutedoc → routedoc');
}
// 2. Set origin='api' on all migrated docs (they were API-created)
const routeCol = db.collection('routedoc');
const result = await routeCol.updateMany(
{ origin: { $exists: false } },
{ $set: { origin: 'api' } },
);
ctx.log.log('info', `Set origin='api' on ${result.modifiedCount} migrated route(s)`);
// 3. Drop routeoverridedoc collection
const overrideCollections = await db.listCollections({ name: 'routeoverridedoc' }).toArray();
if (overrideCollections.length > 0) {
await db.collection('routeoverridedoc').drop();
ctx.log.log('info', 'Dropped routeoverridedoc collection');
}
}); });
return migration; return migration;

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.15.1', version: '13.16.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -2219,58 +2219,6 @@ export const toggleRouteAction = routeManagementStatePart.createAction<{
} }
}); });
export const setRouteOverrideAction = routeManagementStatePart.createAction<{
routeName: string;
enabled: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<IRouteManagementState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_SetRouteOverride
>('/typedrequest', 'setRouteOverride');
await request.fire({
identity: context.identity!,
routeName: dataArg.routeName,
enabled: dataArg.enabled,
});
return await actionContext!.dispatch(fetchMergedRoutesAction, null);
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to set override',
};
}
});
export const removeRouteOverrideAction = routeManagementStatePart.createAction<string>(
async (statePartArg, routeName, actionContext): Promise<IRouteManagementState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_RemoveRouteOverride
>('/typedrequest', 'removeRouteOverride');
await request.fire({
identity: context.identity!,
routeName,
});
return await actionContext!.dispatch(fetchMergedRoutesAction, null);
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to remove override',
};
}
}
);
// ============================================================================ // ============================================================================
// API Token Actions // API Token Actions
// ============================================================================ // ============================================================================

View File

@@ -140,9 +140,9 @@ export class OpsViewRoutes extends DeesElement {
public render(): TemplateResult { public render(): TemplateResult {
const { mergedRoutes, warnings } = this.routeState; const { mergedRoutes, warnings } = this.routeState;
const hardcodedCount = mergedRoutes.filter((mr) => mr.source === 'hardcoded').length;
const programmaticCount = mergedRoutes.filter((mr) => mr.source === 'programmatic').length;
const disabledCount = mergedRoutes.filter((mr) => !mr.enabled).length; const disabledCount = mergedRoutes.filter((mr) => !mr.enabled).length;
const configCount = mergedRoutes.filter((mr) => mr.origin !== 'api').length;
const apiCount = mergedRoutes.filter((mr) => mr.origin === 'api').length;
const statsTiles: IStatsTile[] = [ const statsTiles: IStatsTile[] = [
{ {
@@ -155,19 +155,19 @@ export class OpsViewRoutes extends DeesElement {
color: '#3b82f6', color: '#3b82f6',
}, },
{ {
id: 'hardcoded', id: 'configRoutes',
title: 'Hardcoded', title: 'From Config',
type: 'number', type: 'number',
value: hardcodedCount, value: configCount,
icon: 'lucide:lock', icon: 'lucide:settings',
description: 'Routes from constructor config', description: 'Seeded from config/email/DNS',
color: '#8b5cf6', color: '#8b5cf6',
}, },
{ {
id: 'programmatic', id: 'apiRoutes',
title: 'Programmatic', title: 'API Created',
type: 'number', type: 'number',
value: programmaticCount, value: apiCount,
icon: 'lucide:code', icon: 'lucide:code',
description: 'Routes added via API', description: 'Routes added via API',
color: '#0ea5e9', color: '#0ea5e9',
@@ -186,15 +186,14 @@ export class OpsViewRoutes extends DeesElement {
// Map merged routes to sz-route-list-view format // Map merged routes to sz-route-list-view format
const szRoutes = mergedRoutes.map((mr) => { const szRoutes = mergedRoutes.map((mr) => {
const tags = [...(mr.route.tags || [])]; const tags = [...(mr.route.tags || [])];
tags.push(mr.source); tags.push(mr.origin);
if (!mr.enabled) tags.push('disabled'); if (!mr.enabled) tags.push('disabled');
if (mr.overridden) tags.push('overridden');
return { return {
...mr.route, ...mr.route,
enabled: mr.enabled, enabled: mr.enabled,
tags, tags,
id: mr.storedRouteId || mr.route.name || undefined, id: mr.id || mr.route.name || undefined,
metadata: mr.metadata, metadata: mr.metadata,
}; };
}); });
@@ -238,7 +237,6 @@ export class OpsViewRoutes extends DeesElement {
? html` ? html`
<sz-route-list-view <sz-route-list-view
.routes=${szRoutes} .routes=${szRoutes}
.showActionsFilter=${(route: any) => route.tags?.includes('programmatic') ?? false}
@route-click=${(e: CustomEvent) => this.handleRouteClick(e)} @route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
@route-edit=${(e: CustomEvent) => this.handleRouteEdit(e)} @route-edit=${(e: CustomEvent) => this.handleRouteEdit(e)}
@route-delete=${(e: CustomEvent) => this.handleRouteDelete(e)} @route-delete=${(e: CustomEvent) => this.handleRouteDelete(e)}
@@ -247,7 +245,7 @@ export class OpsViewRoutes extends DeesElement {
: html` : html`
<div class="empty-state"> <div class="empty-state">
<p>No routes configured</p> <p>No routes configured</p>
<p>Add a programmatic route or check your constructor configuration.</p> <p>Add a route to get started.</p>
</div> </div>
`} `}
</div> </div>
@@ -266,112 +264,56 @@ export class OpsViewRoutes extends DeesElement {
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
if (merged.source === 'hardcoded') { const meta = merged.metadata;
const menuOptions = merged.enabled await DeesModal.createAndShow({
? [ heading: `Route: ${merged.route.name}`,
{ content: html`
name: 'Disable Route', <div style="color: #ccc; padding: 8px 0;">
iconName: 'lucide:pause', <p>Origin: <strong style="color: #0af;">${merged.origin}</strong></p>
action: async (modalArg: any) => { <p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
await appstate.routeManagementStatePart.dispatchAction( <p>ID: <code style="color: #888;">${merged.id}</code></p>
appstate.setRouteOverrideAction, ${meta?.sourceProfileName ? html`<p>Source Profile: <strong style="color: #a78bfa;">${meta.sourceProfileName}</strong></p>` : ''}
{ routeName: merged.route.name!, enabled: false }, ${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></p>` : ''}
); </div>
await modalArg.destroy(); `,
}, menuOptions: [
}, {
{ name: merged.enabled ? 'Disable' : 'Enable',
name: 'Close', iconName: merged.enabled ? 'lucide:pause' : 'lucide:play',
iconName: 'lucide:x', action: async (modalArg: any) => {
action: async (modalArg: any) => await modalArg.destroy(), await appstate.routeManagementStatePart.dispatchAction(
}, appstate.toggleRouteAction,
] { id: merged.id, enabled: !merged.enabled },
: [ );
{ await modalArg.destroy();
name: 'Enable Route',
iconName: 'lucide:play',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.setRouteOverrideAction,
{ routeName: merged.route.name!, enabled: true },
);
await modalArg.destroy();
},
},
{
name: 'Remove Override',
iconName: 'lucide:undo',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.removeRouteOverrideAction,
merged.route.name!,
);
await modalArg.destroy();
},
},
{
name: 'Close',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
];
await DeesModal.createAndShow({
heading: `Route: ${merged.route.name}`,
content: html`
<div style="color: #ccc; padding: 8px 0;">
<p>Source: <strong style="color: #88f;">hardcoded</strong></p>
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled (overridden)'}</strong></p>
<p style="color: #888; font-size: 13px;">Hardcoded routes cannot be edited or deleted, but they can be disabled via an override.</p>
</div>
`,
menuOptions,
});
} else {
// Programmatic route
const meta = merged.metadata;
await DeesModal.createAndShow({
heading: `Route: ${merged.route.name}`,
content: html`
<div style="color: #ccc; padding: 8px 0;">
<p>Source: <strong style="color: #0af;">programmatic</strong></p>
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
<p>ID: <code style="color: #888;">${merged.storedRouteId}</code></p>
${meta?.sourceProfileName ? html`<p>Source Profile: <strong style="color: #a78bfa;">${meta.sourceProfileName}</strong></p>` : ''}
${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></p>` : ''}
</div>
`,
menuOptions: [
{
name: merged.enabled ? 'Disable' : 'Enable',
iconName: merged.enabled ? 'lucide:pause' : 'lucide:play',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.toggleRouteAction,
{ id: merged.storedRouteId!, enabled: !merged.enabled },
);
await modalArg.destroy();
},
}, },
{ },
name: 'Delete', {
iconName: 'lucide:trash-2', name: 'Edit',
action: async (modalArg: any) => { iconName: 'lucide:pencil',
await appstate.routeManagementStatePart.dispatchAction( action: async (modalArg: any) => {
appstate.deleteRouteAction, await modalArg.destroy();
merged.storedRouteId!, this.showEditRouteDialog(merged);
);
await modalArg.destroy();
},
}, },
{ },
name: 'Close', {
iconName: 'lucide:x', name: 'Delete',
action: async (modalArg: any) => await modalArg.destroy(), iconName: 'lucide:trash-2',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.deleteRouteAction,
merged.id,
);
await modalArg.destroy();
}, },
], },
}); {
} name: 'Close',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
],
});
} }
private async handleRouteEdit(e: CustomEvent) { private async handleRouteEdit(e: CustomEvent) {
@@ -381,7 +323,7 @@ export class OpsViewRoutes extends DeesElement {
const merged = this.routeState.mergedRoutes.find( const merged = this.routeState.mergedRoutes.find(
(mr) => mr.route.name === clickedRoute.name, (mr) => mr.route.name === clickedRoute.name,
); );
if (!merged || !merged.storedRouteId) return; if (!merged) return;
this.showEditRouteDialog(merged); this.showEditRouteDialog(merged);
} }
@@ -393,7 +335,7 @@ export class OpsViewRoutes extends DeesElement {
const merged = this.routeState.mergedRoutes.find( const merged = this.routeState.mergedRoutes.find(
(mr) => mr.route.name === clickedRoute.name, (mr) => mr.route.name === clickedRoute.name,
); );
if (!merged || !merged.storedRouteId) return; if (!merged) return;
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({ await DeesModal.createAndShow({
@@ -415,7 +357,7 @@ export class OpsViewRoutes extends DeesElement {
action: async (modalArg: any) => { action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction( await appstate.routeManagementStatePart.dispatchAction(
appstate.deleteRouteAction, appstate.deleteRouteAction,
merged.storedRouteId!, merged.id,
); );
await modalArg.destroy(); await modalArg.destroy();
}, },
@@ -563,7 +505,7 @@ export class OpsViewRoutes extends DeesElement {
await appstate.routeManagementStatePart.dispatchAction( await appstate.routeManagementStatePart.dispatchAction(
appstate.updateRouteAction, appstate.updateRouteAction,
{ {
id: merged.storedRouteId!, id: merged.id,
route: updatedRoute, route: updatedRoute,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined, metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
}, },
@@ -603,7 +545,7 @@ export class OpsViewRoutes extends DeesElement {
]; ];
const createModal = await DeesModal.createAndShow({ const createModal = await DeesModal.createAndShow({
heading: 'Add Programmatic Route', heading: 'Add Route',
content: html` content: html`
<dees-form> <dees-form>
<dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text> <dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text>