Compare commits

...

12 Commits

Author SHA1 Message Date
19398ea836 v13.16.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 17:38:23 +00:00
4aba8cc353 feat(routes): unify route storage and management across config, email, dns, and API origins 2026-04-13 17:38:23 +00:00
5fd036eeb6 v13.15.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 12:15:11 +00:00
cfcb66f1ee fix(monitoring): improve domain activity aggregation for multi-domain and wildcard routes 2026-04-13 12:15:11 +00:00
501f4f9de6 v13.15.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 12:07:13 +00:00
fa926eb10b feat(stats): add typed network stats response fields for bandwidth, domain activity, and protocol distribution 2026-04-13 12:07:13 +00:00
f2d0a9ec1b v13.14.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 11:04:15 +00:00
035173702d feat(network): add bandwidth-ranked IP and domain activity metrics to network monitoring 2026-04-13 11:04:15 +00:00
07a3365496 v13.13.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 09:47:19 +00:00
1c4f7dbb11 feat(dns): add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling 2026-04-13 09:47:19 +00:00
1fdff79dd0 v13.12.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-12 23:46:31 +00:00
59b52d08fa feat(email-domains): support creating email domains on optional subdomains 2026-04-12 23:46:31 +00:00
39 changed files with 1174 additions and 923 deletions

View File

@@ -1,5 +1,46 @@
# 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)
improve domain activity aggregation for multi-domain and wildcard routes
- map route metrics across all configured domains instead of only the first domain
- resolve wildcard domain patterns against active protocol cache entries
- distribute shared route traffic across matched domains and preserve fallback reporting for routes without domain configuration
## 2026-04-13 - 13.15.0 - feat(stats)
add typed network stats response fields for bandwidth, domain activity, and protocol distribution
- extends the network stats request interface with top IP bandwidth, domain activity, and frontend/backend protocol distribution data
- updates app state to use a typed getNetworkStats request instead of casting the response to any
## 2026-04-13 - 13.14.0 - feat(network)
add bandwidth-ranked IP and domain activity metrics to network monitoring
- Expose top IPs by bandwidth and aggregated domain activity from route metrics.
- Replace estimated per-connection values with real per-IP throughput data in ops handlers and stats responses.
- Update the network UI to show bandwidth-ranked IPs and domain activity while removing the recent request table.
## 2026-04-13 - 13.13.0 - feat(dns)
add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling
- adds domain migration support in DnsManager, API handlers, request interfaces, app state, and domains UI
- routes ACME DNS-01 challenges through managed domains using createRecord/deleteRecord for both dcrouter-hosted and provider-managed zones
- enables immediate unregister of deleted dcrouter-hosted DNS records from the embedded DNS server
## 2026-04-12 - 13.12.0 - feat(email-domains)
support creating email domains on optional subdomains
- Add optional subdomain support to email domain creation, persistence, and API interfaces.
- Update the ops UI to collect and submit a subdomain prefix when creating an email domain.
- Bump @design.estate/dees-catalog from ^3.78.0 to ^3.78.2.
## 2026-04-12 - 13.11.0 - feat(email-domains)
add email domain management with DNS provisioning, validation, and ops dashboard support

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.11.0",
"version": "13.16.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -35,7 +35,7 @@
"@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.78.0",
"@design.estate/dees-catalog": "^3.78.2",
"@design.estate/dees-element": "^2.2.4",
"@push.rocks/lik": "^6.4.0",
"@push.rocks/projectinfo": "^5.1.0",

43
pnpm-lock.yaml generated
View File

@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.0
version: 7.1.0
'@design.estate/dees-catalog':
specifier: ^3.78.0
version: 3.78.0(@tiptap/pm@2.27.2)
specifier: ^3.78.2
version: 3.78.2(@tiptap/pm@2.27.2)
'@design.estate/dees-element':
specifier: ^2.2.4
version: 2.2.4
@@ -353,8 +353,8 @@ packages:
'@configvault.io/interfaces@1.0.17':
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
'@design.estate/dees-catalog@3.78.0':
resolution: {integrity: sha512-doc9eYGsFV47Ui7k5FuLXpt3ytC/Q+g+yX+qGU/V4fZpc5KUXpL04/FRzO0AU1wF9Xl9GMmL39CcE2vKj88QAQ==}
'@design.estate/dees-catalog@3.78.2':
resolution: {integrity: sha512-9MKKCvx+vxoIp6UpqVQklreokdg7ZSSODz4FlKyNFqjfZiDDme6pjwxWoMSA+Tn4bkboYyCBosUrVfc0nxa1HA==}
'@design.estate/dees-comms@1.0.30':
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
@@ -368,8 +368,8 @@ packages:
'@design.estate/dees-wcctools@3.8.0':
resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==}
'@design.estate/dees-wcctools@3.8.4':
resolution: {integrity: sha512-KpFK/azK+a/Xpq33pXKcho+tdFKVHhKZM5ArvHqo9QMwTczgp5DZZgowTDUuqAofjZwnuVfCPHK/Pw9e64N46A==}
'@design.estate/dees-wcctools@3.9.0':
resolution: {integrity: sha512-0vZBaGBEGIbl8hx+8BezIIea3U5T7iSHHF9VqlJZGf+nOFIW4zBAxcCljH8YzZ1Yayp6BEjxp/pQXjHN2YB3Jg==}
'@emnapi/core@1.9.2':
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
@@ -1997,6 +1997,12 @@ packages:
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
'@types/dom-mediacapture-transform@0.1.11':
resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==}
'@types/dom-webcodecs@0.1.13':
resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==}
'@types/fs-extra@11.0.4':
resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==}
@@ -3166,6 +3172,9 @@ packages:
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
mediabunny@1.40.1:
resolution: {integrity: sha512-HU/stGzAkdWaJIly6ypbUVgAUvT9kt39DIg0IaErR7/1fwtTmgUYs4i8uEPYcgcjPjbB9gtBmUXOLnXi6J2LDw==}
memory-pager@1.5.0:
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
@@ -4318,7 +4327,7 @@ snapshots:
'@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
'@cloudflare/workers-types': 4.20260405.1
'@design.estate/dees-catalog': 3.78.0(@tiptap/pm@2.27.2)
'@design.estate/dees-catalog': 3.78.2(@tiptap/pm@2.27.2)
'@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.4.0
'@push.rocks/smartdelay': 3.0.5
@@ -4847,11 +4856,11 @@ snapshots:
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
'@design.estate/dees-catalog@3.78.0(@tiptap/pm@2.27.2)':
'@design.estate/dees-catalog@3.78.2(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4
'@design.estate/dees-wcctools': 3.8.4
'@design.estate/dees-wcctools': 3.9.0
'@fortawesome/fontawesome-svg-core': 7.2.0
'@fortawesome/free-brands-svg-icons': 7.2.0
'@fortawesome/free-regular-svg-icons': 7.2.0
@@ -4940,12 +4949,13 @@ snapshots:
- supports-color
- vue
'@design.estate/dees-wcctools@3.8.4':
'@design.estate/dees-wcctools@3.9.0':
dependencies:
'@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4
'@push.rocks/smartdelay': 3.0.5
lit: 3.3.2
mediabunny: 1.40.1
transitivePeerDependencies:
- '@nuxt/kit'
- react
@@ -6915,7 +6925,7 @@ snapshots:
'@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-catalog': 3.78.0(@tiptap/pm@2.27.2)
'@design.estate/dees-catalog': 3.78.2(@tiptap/pm@2.27.2)
'@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4
'@design.estate/dees-wcctools': 3.8.0
@@ -7464,6 +7474,12 @@ snapshots:
dependencies:
'@types/ms': 2.1.0
'@types/dom-mediacapture-transform@0.1.11':
dependencies:
'@types/dom-webcodecs': 0.1.13
'@types/dom-webcodecs@0.1.13': {}
'@types/fs-extra@11.0.4':
dependencies:
'@types/jsonfile': 6.1.4
@@ -8819,6 +8835,11 @@ snapshots:
mdurl@2.0.0: {}
mediabunny@1.40.1:
dependencies:
'@types/dom-mediacapture-transform': 0.1.11
'@types/dom-webcodecs': 0.1.13
memory-pager@1.5.0: {}
micromark-core-commonmark@2.0.3:

View File

@@ -174,62 +174,20 @@ tap.test('Route - should hydrate from IMergedRoute data', async () => {
match: { ports: 443, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] },
},
source: 'programmatic',
id: 'route-123',
enabled: true,
overridden: false,
storedRouteId: 'route-123',
origin: 'api',
createdAt: 1000,
updatedAt: 2000,
});
expect(route.name).toEqual('test-route');
expect(route.source).toEqual('programmatic');
expect(route.id).toEqual('route-123');
expect(route.enabled).toEqual(true);
expect(route.overridden).toEqual(false);
expect(route.storedRouteId).toEqual('route-123');
expect(route.origin).toEqual('api');
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
// =============================================================================

View File

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

View File

@@ -312,8 +312,10 @@ export class DcRouter {
// TypedRouter for API endpoints
public typedrouter = new plugins.typedrequest.TypedRouter();
// Cached constructor routes (computed once during setupSmartProxy, used by RouteConfigManager)
private constructorRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
private seedDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Environment access
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
@@ -549,7 +551,6 @@ export class DcRouter {
await this.targetProfileManager.initialize();
this.routeConfigManager = new RouteConfigManager(
() => this.getConstructorRoutes(),
() => this.smartProxy,
() => this.options.http3,
this.options.vpnConfig?.enabled
@@ -564,7 +565,7 @@ export class DcRouter {
}
: undefined,
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
(routes) => {
if (this.remoteIngressManager) {
@@ -577,7 +578,11 @@ export class DcRouter {
);
this.apiTokenManager = new ApiTokenManager();
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
const seeder = new DbSeeder(this.referenceResolver);
@@ -881,31 +886,30 @@ export class DcRouter {
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.
// 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
this.seedEmailRoutes = [];
if (this.options.emailConfig) {
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig);
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) });
routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
this.seedEmailRoutes = this.generateEmailRoutes(this.options.emailConfig);
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
}
// If DNS is configured, add DNS routes
this.seedDnsRoutes = [];
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
const dnsRoutes = this.generateDnsRoutes();
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(dnsRoutes) });
routes = [...routes, ...dnsRoutes];
this.seedDnsRoutes = this.generateDnsRoutes();
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.seedDnsRoutes) });
}
// 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.
// If no config exists or it's disabled, SmartProxy's own ACME is turned off
// and dcrouter's SmartAcme / certProvisionFunction are not wired.
@@ -930,15 +934,16 @@ export class DcRouter {
}
// Configure DNS-01 challenge if any DnsProviderDoc exists in the DB AND
// ACME is enabled. The DnsManager dispatches each challenge to the right
// provider client based on the FQDN being certificated.
// ACME is enabled. The DnsManager dispatches each challenge through the
// unified createRecord()/deleteRecord() path — works for both dcrouter-hosted
// zones and provider-managed zones. Only domains under management get certs.
let challengeHandlers: any[] = [];
if (
acmeConfig &&
this.dnsManager &&
(await this.dnsManager.hasAcmeCapableProvider())
(await this.dnsManager.hasAnyManagedDomain())
) {
logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (DB providers)');
logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (managed domains)');
const convenientDnsProvider = this.dnsManager.buildAcmeConvenientDnsProvider();
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(convenientDnsProvider);
challengeHandlers.push(dns01Handler);
@@ -951,10 +956,6 @@ export class DcRouter {
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 (routes.length > 0 || this.options.smartProxyConfig) {
logger.log('info', 'Setting up SmartProxy with combined configuration');
@@ -1405,14 +1406,6 @@ export class DcRouter {
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() {
logger.log('info', 'Stopping DcRouter services...');
@@ -1456,17 +1449,16 @@ export class DcRouter {
// Update configuration
this.options.smartProxyConfig = config;
// Update routes on RemoteIngressManager so derived ports stay in sync
if (this.remoteIngressManager && config.routes) {
this.remoteIngressManager.setRoutes(config.routes as any[]);
}
// Start new SmartProxy with updated configuration (will include email routes if configured)
// Start new SmartProxy with updated configuration (rebuilds seed routes)
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) {
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');
@@ -2184,13 +2176,14 @@ export class DcRouter {
this.remoteIngressManager = new RemoteIngressManager();
await this.remoteIngressManager.initialize();
// Pass current routes so the manager can derive edge ports from remoteIngress-tagged routes
const currentRoutes = this.constructorRoutes;
this.remoteIngressManager.setRoutes(currentRoutes as any[]);
// Pass current bootstrap routes so the manager can derive edge ports initially.
// Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
// 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
// so the callback delivers the full merged set (including DB-stored routes)
// to our newly-created remoteIngressManager.
// If ConfigManagers finished before us, re-apply routes
// so the callback delivers the full DB set to our newly-created remoteIngressManager.
if (this.routeConfigManager) {
await this.routeConfigManager.applyRoutes();
}
@@ -2277,11 +2270,10 @@ export class DcRouter {
if (!this.targetProfileManager) return [...ips];
const routes = (this.options.smartProxyConfig?.routes || []) as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[];
const storedRoutes = this.routeConfigManager?.getStoredRoutes() || new Map();
const allRoutes = this.routeConfigManager?.getRoutes() || new Map();
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
targetProfileIds, routes, storedRoutes,
targetProfileIds, allRoutes,
);
// Add target IPs directly
@@ -2304,9 +2296,8 @@ export class DcRouter {
await this.vpnManager.start();
// Re-apply routes now that VPN clients are loaded — ensures hardcoded routes
// get correct profile-based ipAllowLists (not possible during setupSmartProxy since
// VPN server wasn't ready yet)
// Re-apply routes now that VPN clients are loaded — ensures vpnOnly routes
// get correct profile-based ipAllowLists
await this.routeConfigManager?.applyRoutes();
}

View File

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

View File

@@ -1,9 +1,8 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { StoredRouteDoc, RouteOverrideDoc } from '../db/index.js';
import { RouteDoc } from '../db/index.js';
import type {
IStoredRoute,
IRouteOverride,
IRoute,
IMergedRoute,
IRouteWarning,
IRouteMetadata,
@@ -46,13 +45,11 @@ class RouteUpdateMutex {
}
export class RouteConfigManager {
private storedRoutes = new Map<string, IStoredRoute>();
private overrides = new Map<string, IRouteOverride>();
private routes = new Map<string, IRoute>();
private warnings: IRouteWarning[] = [];
private routeUpdateMutex = new RouteUpdateMutex();
constructor(
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined,
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
@@ -60,52 +57,44 @@ export class RouteConfigManager {
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
) {}
/** Expose stored routes map for reference resolution lookups. */
public getStoredRoutes(): Map<string, IStoredRoute> {
return this.storedRoutes;
/** Expose routes map for reference resolution lookups. */
public getRoutes(): Map<string, IRoute> {
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> {
await this.loadStoredRoutes();
await this.loadOverrides();
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();
}
// =========================================================================
// Merged view
// Route listing
// =========================================================================
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
const merged: IMergedRoute[] = [];
// Hardcoded routes
for (const route of this.getHardcodedRoutes()) {
const name = route.name || '';
const override = this.overrides.get(name);
for (const route of this.routes.values()) {
merged.push({
route,
source: 'hardcoded',
enabled: override ? override.enabled : true,
overridden: !!override,
});
}
// 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,
route: route.route,
id: route.id,
enabled: route.enabled,
origin: route.origin,
createdAt: route.createdAt,
updatedAt: route.updatedAt,
metadata: route.metadata,
});
}
@@ -113,7 +102,7 @@ export class RouteConfigManager {
}
// =========================================================================
// Programmatic route CRUD
// Route CRUD
// =========================================================================
public async createRoute(
@@ -127,7 +116,7 @@ export class RouteConfigManager {
// Ensure route has a 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
@@ -138,17 +127,18 @@ export class RouteConfigManager {
resolvedMetadata = resolved.metadata;
}
const stored: IStoredRoute = {
const stored: IRoute = {
id,
route,
enabled,
createdAt: now,
updatedAt: now,
createdBy,
origin: 'api',
metadata: resolvedMetadata,
};
this.storedRoutes.set(id, stored);
this.routes.set(id, stored);
await this.persistRoute(stored);
await this.applyRoutes();
return id;
@@ -162,7 +152,7 @@ export class RouteConfigManager {
metadata?: Partial<IRouteMetadata>;
},
): Promise<boolean> {
const stored = this.storedRoutes.get(id);
const stored = this.routes.get(id);
if (!stored) return false;
if (patch.route) {
@@ -201,9 +191,9 @@ export class RouteConfigManager {
}
public async deleteRoute(id: string): Promise<boolean> {
if (!this.storedRoutes.has(id)) return false;
this.storedRoutes.delete(id);
const doc = await StoredRouteDoc.findById(id);
if (!this.routes.has(id)) return false;
this.routes.delete(id);
const doc = await RouteDoc.findById(id);
if (doc) await doc.delete();
await this.applyRoutes();
return true;
@@ -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 = {
routeName,
enabled,
updatedAt: Date.now(),
updatedBy,
};
this.overrides.set(routeName, override);
const existingDoc = await RouteOverrideDoc.findByRouteName(routeName);
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();
}
/**
* Upsert seed routes by name+origin. Preserves user's `enabled` state.
* Deletes stale DB routes whose origin matches but name is not in the seed set.
*/
private async seedRoutes(
seedRoutes: IDcRouterRouteConfig[],
origin: 'config' | 'email' | 'dns',
): Promise<void> {
if (seedRoutes.length === 0) return;
public async removeOverride(routeName: string): Promise<boolean> {
if (!this.overrides.has(routeName)) return false;
this.overrides.delete(routeName);
const doc = await RouteOverrideDoc.findByRouteName(routeName);
if (doc) await doc.delete();
this.computeWarnings();
await this.applyRoutes();
return true;
const seedNames = new Set<string>();
let seeded = 0;
let updated = 0;
for (const route of seedRoutes) {
const name = route.name || '';
seedNames.add(name);
// Check if a route with this name+origin already exists in memory
let existingId: string | undefined;
for (const [id, r] of this.routes) {
if (r.origin === origin && r.route.name === name) {
existingId = id;
break;
}
}
if (existingId) {
// Update route config but preserve enabled state
const existing = this.routes.get(existingId)!;
existing.route = route;
existing.updatedAt = Date.now();
await this.persistRoute(existing);
updated++;
} else {
// Insert new seed route
const id = plugins.uuid.v4();
const now = Date.now();
const newRoute: IRoute = {
id,
route,
enabled: true,
createdAt: now,
updatedAt: now,
createdBy: 'system',
origin,
};
this.routes.set(id, newRoute);
await this.persistRoute(newRoute);
seeded++;
}
}
// Delete stale routes: same origin but name not in current seed set
const staleIds: string[] = [];
for (const [id, r] of this.routes) {
if (r.origin === origin && !seedNames.has(r.route.name || '')) {
staleIds.push(id);
}
}
for (const id of staleIds) {
this.routes.delete(id);
const doc = await RouteDoc.findById(id);
if (doc) await doc.delete();
}
if (seeded > 0 || updated > 0 || staleIds.length > 0) {
logger.log('info', `Seed routes (${origin}): ${seeded} new, ${updated} updated, ${staleIds.length} stale removed`);
}
}
// =========================================================================
// Private: persistence
// =========================================================================
private async loadStoredRoutes(): Promise<void> {
const docs = await StoredRouteDoc.findAll();
private async loadRoutes(): Promise<void> {
const docs = await RouteDoc.findAll();
for (const doc of docs) {
if (doc.id) {
this.storedRoutes.set(doc.id, {
this.routes.set(doc.id, {
id: doc.id,
route: doc.route,
enabled: doc.enabled,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
origin: doc.origin || 'api',
metadata: doc.metadata,
});
}
}
if (this.storedRoutes.size > 0) {
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`);
if (this.routes.size > 0) {
logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
}
}
private async loadOverrides(): Promise<void> {
const docs = await RouteOverrideDoc.findAll();
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);
private async persistRoute(stored: IRoute): Promise<void> {
const existingDoc = await RouteDoc.findById(stored.id);
if (existingDoc) {
existingDoc.route = stored.route;
existingDoc.enabled = stored.enabled;
existingDoc.updatedAt = stored.updatedAt;
existingDoc.createdBy = stored.createdBy;
existingDoc.origin = stored.origin;
existingDoc.metadata = stored.metadata;
await existingDoc.save();
} else {
const doc = new StoredRouteDoc();
const doc = new RouteDoc();
doc.id = stored.id;
doc.route = stored.route;
doc.enabled = stored.enabled;
doc.createdAt = stored.createdAt;
doc.updatedAt = stored.updatedAt;
doc.createdBy = stored.createdBy;
doc.origin = stored.origin;
doc.metadata = stored.metadata;
await doc.save();
}
@@ -322,33 +333,14 @@ export class RouteConfigManager {
private computeWarnings(): void {
this.warnings = [];
const hardcodedNames = new Set(this.getHardcodedRoutes().map((r) => r.name || ''));
// Check overrides
for (const [routeName, override] of this.overrides) {
if (!hardcodedNames.has(routeName)) {
for (const route of this.routes.values()) {
if (!route.enabled) {
const name = route.route.name || route.id;
this.warnings.push({
type: 'orphaned-override',
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',
type: 'disabled-route',
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;
for (const routeId of routeIds) {
const stored = this.storedRoutes.get(routeId);
const stored = this.routes.get(routeId);
if (!stored?.metadata) continue;
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> {
@@ -416,35 +408,25 @@ export class RouteConfigManager {
};
};
// Add enabled hardcoded routes (respecting overrides, with fresh VPN injection)
for (const route of this.getHardcodedRoutes()) {
const name = route.name || '';
const override = this.overrides.get(name);
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;
// Add all enabled routes with HTTP/3 and VPN augmentation
for (const route of this.routes.values()) {
if (route.enabled) {
let r = route.route;
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);
// Notify listeners (e.g. RemoteIngressManager) of the merged route set
// Notify listeners (e.g. RemoteIngressManager) of the route set
if (this.onRoutesApplied) {
this.onRoutesApplied(enabledRoutes);
}
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.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 type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.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).
@@ -220,8 +220,7 @@ export class TargetProfileManager {
*/
public getClientAccessSpec(
targetProfileIds: string[],
allRoutes: IDcRouterRouteConfig[],
storedRoutes: Map<string, IStoredRoute>,
allRoutes: Map<string, IRoute>,
): { domains: string[]; targetIps: string[] } {
const domains = new Set<string>();
const targetIps = new Set<string>();
@@ -245,23 +244,11 @@ export class TargetProfileManager {
}
}
// Route references: scan constructor routes
for (const route of allRoutes) {
if (this.routeMatchesProfile(route as IDcRouterRouteConfig, undefined, profile)) {
const routeDomains = (route.match as any)?.domains;
if (Array.isArray(routeDomains)) {
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;
// Route references: scan all routes
for (const [routeId, route] of allRoutes) {
if (!route.enabled) continue;
if (this.routeMatchesProfile(route.route as IDcRouterRouteConfig, routeId, profile)) {
const routeDomains = (route.route.match as any)?.domains;
if (Array.isArray(routeDomains)) {
for (const d of routeDomains) {
domains.add(d);

View File

@@ -20,6 +20,9 @@ export class EmailDomainDoc extends plugins.smartdata.SmartDataDbDoc<EmailDomain
@plugins.smartdata.svDb()
public linkedDomainId: string = '';
@plugins.smartdata.svDb()
public subdomain?: string;
@plugins.smartdata.svDb()
public dkim!: IEmailDomainDkim;

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();
@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.svDb()
public id!: string;
@@ -26,6 +26,9 @@ export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRoute
@plugins.smartdata.svDb()
public createdBy!: string;
@plugins.smartdata.svDb()
public origin!: 'config' | 'email' | 'dns' | 'api';
@plugins.smartdata.svDb()
public metadata?: IRouteMetadata;
@@ -33,11 +36,19 @@ export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRoute
super();
}
public static async findById(id: string): Promise<StoredRouteDoc | null> {
return await StoredRouteDoc.getInstance({ id });
public static async findById(id: string): Promise<RouteDoc | null> {
return await RouteDoc.getInstance({ id });
}
public static async findAll(): Promise<StoredRouteDoc[]> {
return await StoredRouteDoc.getInstances({});
public static async findAll(): Promise<RouteDoc[]> {
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';
// Config document classes
export * from './classes.stored-route.doc.js';
export * from './classes.route-override.doc.js';
export * from './classes.route.doc.js';
export * from './classes.api-token.doc.js';
export * from './classes.source-profile.doc.js';
export * from './classes.target-profile.doc.js';

View File

@@ -296,70 +296,99 @@ export class DnsManager {
}
/**
* True if any cloudflare provider exists in the DB. Used by setupSmartProxy()
* to decide whether to wire SmartAcme with a DNS-01 handler.
* Find the DomainDoc that covers a given FQDN, regardless of source
* (dcrouter-hosted or provider-managed). Uses longest-suffix match.
*/
public async hasAcmeCapableProvider(): Promise<boolean> {
const providers = await DnsProviderDoc.findAll();
return providers.length > 0;
public async findDomainForFqdn(fqdn: string): Promise<DomainDoc | null> {
const lower = fqdn.toLowerCase().replace(/^\*\./, '').replace(/\.$/, '');
const allDomains = await DomainDoc.findAll();
// Sort by name length descending for longest-match-wins
allDomains.sort((a, b) => b.name.length - a.name.length);
for (const domain of allDomains) {
if (lower === domain.name || lower.endsWith(`.${domain.name}`)) {
return domain;
}
}
return null;
}
/**
* Build an IConvenientDnsProvider that dispatches each ACME challenge to
* the right provider client (whichever provider type owns the parent zone),
* based on the challenge's hostName. Provider-agnostic — uses the IDnsProviderClient
* interface, so any registered provider implementation works.
* Returned object plugs directly into smartacme's Dns01Handler.
* Delete all DNS records matching a name and type under a domain.
* Used for ACME challenge cleanup (may have multiple TXT records at the same name).
*/
public async deleteRecordsByNameAndType(
domainId: string,
name: string,
type: TDnsRecordType,
): Promise<void> {
const records = await DnsRecordDoc.findByDomainId(domainId);
for (const rec of records) {
if (rec.name.toLowerCase() === name.toLowerCase() && rec.type === type) {
await this.deleteRecord(rec.id);
}
}
}
/**
* True if any domain is under management (dcrouter-hosted or provider-managed).
* Used by setupSmartProxy() to decide whether to wire SmartAcme with a DNS-01 handler.
*/
public async hasAnyManagedDomain(): Promise<boolean> {
const domains = await DomainDoc.findAll();
return domains.length > 0;
}
/**
* Build an IConvenientDnsProvider that routes ACME DNS-01 challenges through
* the DnsManager abstraction. Challenges are dispatched via createRecord() /
* deleteRecord(), which transparently handle both dcrouter-hosted zones
* (embedded DnsServer) and provider-managed zones (e.g. Cloudflare API).
*
* Only domains under management (with a DomainDoc in DB) are supported —
* this acts as the management gate for certificate issuance.
*/
public buildAcmeConvenientDnsProvider(): plugins.tsclass.network.IConvenientDnsProvider {
const self = this;
const adapter = {
async acmeSetDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
if (!client) {
const domainDoc = await self.findDomainForFqdn(dnsChallenge.hostName);
if (!domainDoc) {
throw new Error(
`DnsManager: no DNS provider configured for ${dnsChallenge.hostName}. ` +
'Add one in the Domains > Providers UI before issuing certificates.',
`DnsManager: no managed domain found for ${dnsChallenge.hostName}. ` +
'Add the domain in Domains before issuing certificates.',
);
}
// Clean any leftover challenge records first to avoid duplicates.
// Clean leftover challenge records first to avoid duplicates.
try {
const existing = await client.listRecords(dnsChallenge.hostName);
for (const r of existing) {
if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId).catch(() => {});
}
}
await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
} catch (err: unknown) {
logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
}
await client.createRecord(dnsChallenge.hostName, {
// Create the challenge TXT record via the unified path
await self.createRecord({
domainId: domainDoc.id,
name: dnsChallenge.hostName,
type: 'TXT',
value: dnsChallenge.challenge,
ttl: 120,
createdBy: 'acme-dns01',
});
},
async acmeRemoveDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
if (!client) {
const domainDoc = await self.findDomainForFqdn(dnsChallenge.hostName);
if (!domainDoc) {
// The domain may have been removed; nothing to clean up.
return;
}
try {
const existing = await client.listRecords(dnsChallenge.hostName);
for (const r of existing) {
if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId);
}
}
await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
} catch (err: unknown) {
logger.log('warn', `DnsManager: failed to remove TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
}
},
async isDomainSupported(domain: string): Promise<boolean> {
const client = await self.getProviderClientForDomain(domain);
return !!client;
const domainDoc = await self.findDomainForFqdn(domain);
return !!domainDoc;
},
};
return { convenience: adapter } as plugins.tsclass.network.IConvenientDnsProvider;
@@ -642,6 +671,151 @@ export class DnsManager {
return await DnsRecordDoc.findById(id);
}
// ==========================================================================
// Domain migration
// ==========================================================================
/**
* Migrate a domain between dcrouter-hosted and provider-managed.
* Transfers all records to the target and updates domain metadata.
*/
public async migrateDomain(args: {
id: string;
targetSource: 'dcrouter' | 'provider';
targetProviderId?: string;
deleteExistingProviderRecords?: boolean;
}): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> {
const domain = await DomainDoc.findById(args.id);
if (!domain) return { success: false, message: 'Domain not found' };
if (domain.source === args.targetSource && domain.providerId === args.targetProviderId) {
return { success: false, message: 'Domain is already in the target configuration' };
}
const records = await DnsRecordDoc.findByDomainId(domain.id);
if (args.targetSource === 'provider') {
return this.migrateToDnsProvider(domain, records, args.targetProviderId!, args.deleteExistingProviderRecords ?? false);
} else {
return this.migrateToDcrouter(domain, records);
}
}
/**
* Migrate domain from dcrouter-hosted (or another provider) to an external DNS provider.
*/
private async migrateToDnsProvider(
domain: DomainDoc,
records: DnsRecordDoc[],
targetProviderId: string,
deleteExistingProviderRecords: boolean,
): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> {
// Validate the target provider exists
const client = await this.getProviderClientById(targetProviderId);
if (!client) {
return { success: false, message: 'Target DNS provider not found' };
}
// Find the zone at the provider
const providerDomains = await client.listDomains();
const zone = providerDomains.find(
(z) => z.name.toLowerCase() === domain.name.toLowerCase(),
);
if (!zone) {
return { success: false, message: `Zone "${domain.name}" not found at the target provider` };
}
// Optionally delete existing records at the provider
if (deleteExistingProviderRecords) {
try {
const existingProviderRecords = await client.listRecords(domain.name);
for (const pr of existingProviderRecords) {
await client.deleteRecord(domain.name, pr.providerRecordId).catch(() => {});
}
logger.log('info', `Deleted ${existingProviderRecords.length} existing records at provider for ${domain.name}`);
} catch (err: unknown) {
logger.log('warn', `Failed to clean existing provider records for ${domain.name}: ${(err as Error).message}`);
}
}
// Push each local record to the provider
let migrated = 0;
for (const rec of records) {
try {
const providerRecord = await client.createRecord(domain.name, {
name: rec.name,
type: rec.type as any,
value: rec.value,
ttl: rec.ttl,
});
// Unregister from embedded DnsServer if it was dcrouter-hosted
if (domain.source === 'dcrouter') {
this.unregisterRecordFromDnsServer(rec);
}
// Update the record doc to synced
rec.source = 'synced' as TDnsRecordSource;
rec.providerRecordId = providerRecord.providerRecordId;
await rec.save();
migrated++;
} catch (err: unknown) {
logger.log('warn', `Failed to migrate record ${rec.name} ${rec.type} to provider: ${(err as Error).message}`);
}
}
// Update domain metadata
domain.source = 'provider';
domain.authoritative = false;
domain.providerId = targetProviderId;
domain.externalZoneId = zone.externalId;
domain.nameservers = zone.nameservers;
domain.lastSyncedAt = Date.now();
domain.updatedAt = Date.now();
await domain.save();
logger.log('info', `Domain ${domain.name} migrated to provider (${migrated} records)`);
return { success: true, recordsMigrated: migrated };
}
/**
* Migrate domain from provider-managed to dcrouter-hosted (authoritative).
*/
private async migrateToDcrouter(
domain: DomainDoc,
records: DnsRecordDoc[],
): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> {
// Register each record with the embedded DnsServer
let migrated = 0;
for (const rec of records) {
try {
this.registerRecordWithDnsServer(rec);
// Update the record doc to local
rec.source = 'local' as TDnsRecordSource;
rec.providerRecordId = undefined;
await rec.save();
migrated++;
} catch (err: unknown) {
logger.log('warn', `Failed to register record ${rec.name} ${rec.type} with DnsServer: ${(err as Error).message}`);
}
}
// Update domain metadata
domain.source = 'dcrouter';
domain.authoritative = true;
domain.providerId = undefined;
domain.externalZoneId = undefined;
domain.nameservers = undefined;
domain.lastSyncedAt = undefined;
domain.updatedAt = Date.now();
await domain.save();
logger.log('info', `Domain ${domain.name} migrated to dcrouter (${migrated} records)`);
return { success: true, recordsMigrated: migrated };
}
// ==========================================================================
// Record CRUD
// ==========================================================================
public async createRecord(args: {
domainId: string;
name: string;
@@ -759,14 +933,24 @@ export class DnsManager {
}
}
}
// For local records: smartdns has no unregister API in the pinned version,
// so the record stays served until the next restart. The DB delete still
// takes effect — on restart, the record will not be re-registered.
// For dcrouter-hosted records: unregister the handler from the embedded DnsServer
// so the record stops being served immediately (not just after restart).
if (domain.source === 'dcrouter' && this.dnsServer) {
this.unregisterRecordFromDnsServer(doc);
}
await doc.delete();
return { success: true };
}
/**
* Unregister a record's handler from the embedded DnsServer.
*/
public unregisterRecordFromDnsServer(rec: DnsRecordDoc): void {
if (!this.dnsServer) return;
this.dnsServer.unregisterHandler(rec.name, [rec.type]);
}
// ==========================================================================
// Internal helpers
// ==========================================================================

View File

@@ -48,6 +48,7 @@ export class EmailDomainManager {
public async createEmailDomain(opts: {
linkedDomainId: string;
subdomain?: string;
dkimSelector?: string;
dkimKeySize?: number;
rotateKeys?: boolean;
@@ -58,7 +59,9 @@ export class EmailDomainManager {
if (!domainDoc) {
throw new Error(`DNS domain not found: ${opts.linkedDomainId}`);
}
const domainName = domainDoc.name;
const baseDomain = domainDoc.name;
const subdomain = opts.subdomain?.trim() || undefined;
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
// Check for duplicates
const existing = await EmailDomainDoc.findByDomain(domainName);
@@ -90,6 +93,7 @@ export class EmailDomainManager {
doc.id = plugins.smartunique.shortId();
doc.domain = domainName.toLowerCase();
doc.linkedDomainId = opts.linkedDomainId;
doc.subdomain = subdomain;
doc.dkim = {
selector,
keySize,
@@ -306,6 +310,7 @@ export class EmailDomainManager {
id: doc.id,
domain: doc.domain,
linkedDomainId: doc.linkedDomainId,
subdomain: doc.subdomain,
dkim: doc.dkim,
rateLimits: doc.rateLimits,
dnsStatus: doc.dnsStatus,

View File

@@ -553,12 +553,14 @@ export class MetricsManager {
connectionsByIP: new Map<string, number>(),
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [] as Array<{ ip: string; count: number }>,
topIPsByBandwidth: [] as Array<{ ip: string; count: number; bwIn: number; bwOut: number }>,
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
throughputByIP: new Map<string, { in: number; out: number }>(),
requestsPerSecond: 0,
requestsTotal: 0,
backends: [] as Array<any>,
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number }>,
};
}
@@ -572,7 +574,7 @@ export class MetricsManager {
bytesOutPerSecond: instantThroughput.out
};
// Get top IPs
// Get top IPs by connection count
const topIPs = proxyMetrics.connections.topIPs(10);
// Get total data transferred
@@ -699,10 +701,156 @@ export class MetricsManager {
}
}
// Build top 10 IPs by bandwidth (sorted by total throughput desc)
const allIPData = new Map<string, { count: number; bwIn: number; bwOut: number }>();
for (const [ip, count] of connectionsByIP) {
allIPData.set(ip, { count, bwIn: 0, bwOut: 0 });
}
for (const [ip, tp] of throughputByIP) {
const existing = allIPData.get(ip);
if (existing) {
existing.bwIn = tp.in;
existing.bwOut = tp.out;
} else {
allIPData.set(ip, { count: 0, bwIn: tp.in, bwOut: tp.out });
}
}
const topIPsByBandwidth = Array.from(allIPData.entries())
.sort((a, b) => (b[1].bwIn + b[1].bwOut) - (a[1].bwIn + a[1].bwOut))
.slice(0, 10)
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
// Build domain activity from per-route metrics
const connectionsByRoute = proxyMetrics.connections.byRoute();
const throughputByRoute = proxyMetrics.throughput.byRoute();
// Map route name → ALL its domains (not just the first one)
const routeDomains = new Map<string, string[]>();
if (this.dcRouter.smartProxy) {
for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
if (!route.name || !route.match.domains) continue;
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
if (domains.length > 0) {
routeDomains.set(route.name, domains);
}
}
}
// Use protocol cache to discover actual active domains (resolves wildcards)
const activeDomains = new Set<string>();
const domainToBackend = new Map<string, string>(); // domain → host:port
for (const entry of protocolCache) {
if (entry.domain) {
activeDomains.add(entry.domain);
domainToBackend.set(entry.domain, `${entry.host}:${entry.port}`);
}
}
// Build reverse map: domain → route name(s) that handle it
// For concrete domains: direct lookup from route config
// For wildcard patterns: match active domains from protocol cache
const domainToRoutes = new Map<string, string[]>();
for (const [routeName, domains] of routeDomains) {
for (const pattern of domains) {
if (pattern.includes('*')) {
// Wildcard pattern — match against active domains from protocol cache
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
for (const activeDomain of activeDomains) {
if (regex.test(activeDomain)) {
const existing = domainToRoutes.get(activeDomain);
if (existing) { existing.push(routeName); }
else { domainToRoutes.set(activeDomain, [routeName]); }
}
}
} else {
// Concrete domain
const existing = domainToRoutes.get(pattern);
if (existing) { existing.push(routeName); }
else { domainToRoutes.set(pattern, [routeName]); }
}
}
}
// Aggregate metrics per domain
// For each domain, sum metrics from all routes that serve it,
// divided by the number of domains each route serves
const domainAgg = new Map<string, {
activeConnections: number;
bytesInPerSec: number;
bytesOutPerSec: number;
routeCount: number;
}>();
// Track which routes are accounted for
const accountedRoutes = new Set<string>();
for (const [domain, routeNames] of domainToRoutes) {
let totalConns = 0;
let totalIn = 0;
let totalOut = 0;
for (const routeName of routeNames) {
accountedRoutes.add(routeName);
const conns = connectionsByRoute.get(routeName) || 0;
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
// Count how many resolved domains share this route
let domainsInRoute = 0;
for (const [, routes] of domainToRoutes) {
if (routes.includes(routeName)) domainsInRoute++;
}
const share = Math.max(domainsInRoute, 1);
totalConns += conns / share;
totalIn += tp.in / share;
totalOut += tp.out / share;
}
domainAgg.set(domain, {
activeConnections: Math.round(totalConns),
bytesInPerSec: totalIn,
bytesOutPerSec: totalOut,
routeCount: routeNames.length,
});
}
// Include routes with no domain config (fallback: use route name)
for (const [routeName, activeConns] of connectionsByRoute) {
if (accountedRoutes.has(routeName)) continue;
if (routeDomains.has(routeName)) continue; // has domains but no traffic matched
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
if (activeConns === 0 && tp.in === 0 && tp.out === 0) continue;
const existing = domainAgg.get(routeName);
if (existing) {
existing.activeConnections += activeConns;
existing.bytesInPerSec += tp.in;
existing.bytesOutPerSec += tp.out;
existing.routeCount++;
} else {
domainAgg.set(routeName, {
activeConnections: activeConns,
bytesInPerSec: tp.in,
bytesOutPerSec: tp.out,
routeCount: 1,
});
}
}
const domainActivity = Array.from(domainAgg.entries())
.map(([domain, data]) => ({
domain,
bytesInPerSecond: data.bytesInPerSec,
bytesOutPerSecond: data.bytesOutPerSec,
activeConnections: data.activeConnections,
routeCount: data.routeCount,
}))
.sort((a, b) => (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));
return {
connectionsByIP,
throughputRate,
topIPs,
topIPsByBandwidth,
totalDataTransferred,
throughputHistory,
throughputByIP,
@@ -711,6 +859,7 @@ export class MetricsManager {
backends,
frontendProtocols,
backendProtocols,
domainActivity,
};
}, 1000); // 1s cache — matches typical dashboard poll interval
}

View File

@@ -127,7 +127,7 @@ export class ConfigHandler {
// (replaces the legacy `dnsChallenge.cloudflareApiKey` constructor field).
let dnsChallengeEnabled = false;
try {
dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAcmeCapableProvider()) ?? false;
dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAnyManagedDomain()) ?? false;
} catch {
dnsChallengeEnabled = false;
}

View File

@@ -157,5 +157,23 @@ export class DomainHandler {
},
),
);
// Migrate domain between dcrouter-hosted and provider-managed
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_MigrateDomain>(
'migrateDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'domains:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
return await dnsManager.migrateDomain({
id: dataArg.id,
targetSource: dataArg.targetSource,
targetProviderId: dataArg.targetProviderId,
deleteExistingProviderRecords: dataArg.deleteExistingProviderRecords,
});
},
),
);
}
}

View File

@@ -85,6 +85,7 @@ export class EmailDomainHandler {
try {
const domain = await this.manager.createEmailDomain({
linkedDomainId: dataArg.linkedDomainId,
subdomain: dataArg.subdomain,
dkimSelector: dataArg.dkimSelector,
dkimKeySize: dataArg.dkimKeySize,
rotateKeys: dataArg.rotateKeys,

View File

@@ -135,7 +135,7 @@ export class NetworkTargetHandler {
const result = await resolver.deleteTarget(
dataArg.id,
dataArg.force ?? false,
manager.getStoredRoutes(),
manager.getRoutes(),
);
if (result.success && dataArg.force) {
@@ -158,7 +158,7 @@ export class NetworkTargetHandler {
if (!resolver || !manager) {
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 })) };
},
),

View File

@@ -72,7 +72,7 @@ export class RouteManagementHandler {
return { success: false, message: 'Route management not initialized' };
}
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
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
// Toggle route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleRoute>(
'toggleRoute',

View File

@@ -51,8 +51,8 @@ export class SecurityHandler {
startTime: conn.startTime,
protocol: conn.type === 'http' ? 'https' : conn.type as any,
state: conn.status as any,
bytesReceived: Math.floor(conn.bytesTransferred / 2),
bytesSent: Math.floor(conn.bytesTransferred / 2),
bytesReceived: (conn as any)._throughputIn || 0,
bytesSent: (conn as any)._throughputOut || 0,
}));
const summary = {
@@ -96,9 +96,11 @@ export class SecurityHandler {
connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })),
throughputRate: networkStats.throughputRate,
topIPs: networkStats.topIPs,
topIPsByBandwidth: networkStats.topIPsByBandwidth,
totalDataTransferred: networkStats.totalDataTransferred,
throughputHistory: networkStats.throughputHistory || [],
throughputByIP,
domainActivity: networkStats.domainActivity || [],
requestsPerSecond: networkStats.requestsPerSecond || 0,
requestsTotal: networkStats.requestsTotal || 0,
backends: networkStats.backends || [],
@@ -110,9 +112,11 @@ export class SecurityHandler {
connectionsByIP: [],
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [],
topIPsByBandwidth: [],
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [],
throughputByIP: [],
domainActivity: [],
requestsPerSecond: 0,
requestsTotal: 0,
backends: [],
@@ -251,31 +255,31 @@ export class SecurityHandler {
const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo();
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
// Use IP-based connection data from the new metrics API
// One aggregate row per IP with real throughput data
if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) {
let connIndex = 0;
const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server';
for (const [ip, count] of networkStats.connectionsByIP) {
// Create a connection entry for each active IP connection
for (let i = 0; i < Math.min(count, 5); i++) { // Limit to 5 connections per IP for UI performance
connections.push({
id: `conn-${connIndex++}`,
type: 'http',
source: {
ip: ip,
port: Math.floor(Math.random() * 50000) + 10000, // High port range
},
destination: {
ip: publicIp,
port: 443,
service: 'proxy',
},
startTime: Date.now() - Math.floor(Math.random() * 3600000), // Within last hour
bytesTransferred: Math.floor(networkStats.totalDataTransferred.bytesIn / networkStats.connectionsByIP.size),
status: 'active',
});
}
const tp = networkStats.throughputByIP?.get(ip);
connections.push({
id: `ip-${connIndex++}`,
type: 'http',
source: {
ip: ip,
port: 0,
},
destination: {
ip: publicIp,
port: 443,
service: 'proxy',
},
startTime: 0,
bytesTransferred: count, // Store connection count here
status: 'active',
// Attach real throughput for the handler mapping
...(tp ? { _throughputIn: tp.in, _throughputOut: tp.out } : {}),
} as any);
}
} else if (connectionInfo.length > 0) {
// Fallback to route-based connection info if no IP data available

View File

@@ -136,7 +136,7 @@ export class SourceProfileHandler {
const result = await resolver.deleteProfile(
dataArg.id,
dataArg.force ?? false,
manager.getStoredRoutes(),
manager.getRoutes(),
);
// If force-deleted with affected routes, re-apply
@@ -160,7 +160,7 @@ export class SourceProfileHandler {
if (!resolver || !manager) {
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 })) };
},
),

View File

@@ -291,6 +291,20 @@ export class StatsHandler {
}
}
// Build connectionDetails from real per-IP data
const connectionDetails: interfaces.data.IConnectionDetails[] = [];
for (const [ip, count] of stats.connectionsByIP) {
const tp = stats.throughputByIP?.get(ip);
connectionDetails.push({
remoteAddress: ip,
protocol: 'https',
state: 'connected',
startTime: 0,
bytesIn: tp?.in || 0,
bytesOut: tp?.out || 0,
});
}
metrics.network = {
totalBandwidth: {
in: stats.throughputRate.bytesInPerSecond,
@@ -301,12 +315,18 @@ export class StatsHandler {
out: stats.totalDataTransferred.bytesOut,
},
activeConnections: serverStats.activeConnections,
connectionDetails: [],
connectionDetails,
topEndpoints: stats.topIPs.map(ip => ({
endpoint: ip.ip,
requests: ip.count,
connections: ip.count,
bandwidth: ipBandwidth.get(ip.ip) || { in: 0, out: 0 },
})),
topEndpointsByBandwidth: stats.topIPsByBandwidth.map(ip => ({
endpoint: ip.ip,
connections: ip.count,
bandwidth: { in: ip.bwIn, out: ip.bwOut },
})),
domainActivity: stats.domainActivity || [],
throughputHistory: stats.throughputHistory || [],
requestsPerSecond: stats.requestsPerSecond || 0,
requestsTotal: stats.requestsTotal || 0,

View File

@@ -7,10 +7,9 @@ export class Route {
// Data from IMergedRoute
public routeConfig: IRouteConfig;
public source: 'hardcoded' | 'programmatic';
public id: string;
public enabled: boolean;
public overridden: boolean;
public storedRouteId?: string;
public origin: 'config' | 'email' | 'dns' | 'api';
public createdAt?: number;
public updatedAt?: number;
@@ -22,21 +21,17 @@ export class Route {
constructor(clientRef: DcRouterApiClient, data: interfaces.data.IMergedRoute) {
this.clientRef = clientRef;
this.routeConfig = data.route;
this.source = data.source;
this.id = data.id;
this.enabled = data.enabled;
this.overridden = data.overridden;
this.storedRouteId = data.storedRouteId;
this.origin = data.origin;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
}
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>(
'updateRoute',
this.clientRef.buildRequestPayload({ id: this.storedRouteId, route: changes }) as any,
this.clientRef.buildRequestPayload({ id: this.id, route: changes }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to update route');
@@ -44,12 +39,9 @@ export class Route {
}
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>(
'deleteRoute',
this.clientRef.buildRequestPayload({ id: this.storedRouteId }) as any,
this.clientRef.buildRequestPayload({ id: this.id }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to delete route');
@@ -57,41 +49,15 @@ export class Route {
}
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>(
'toggleRoute',
this.clientRef.buildRequestPayload({ id: this.storedRouteId, enabled }) as any,
this.clientRef.buildRequestPayload({ id: this.id, enabled }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to toggle route');
}
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 {
@@ -144,9 +110,8 @@ export class RouteBuilder {
}
// 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 created = routes.find((r) => r.storedRouteId === response.storedRouteId);
const created = routes.find((r) => r.id === response.routeId);
if (created) {
return created;
}
@@ -154,10 +119,9 @@ export class RouteBuilder {
// Fallback: construct from known data
return new Route(this.clientRef, {
route: this.routeConfig as IRouteConfig,
source: 'programmatic',
id: response.routeId || '',
enabled: this.isEnabled,
overridden: false,
storedRouteId: response.storedRouteId,
origin: 'api',
});
}
}
@@ -190,10 +154,9 @@ export class RouteManager {
}
return new Route(this.clientRef, {
route: routeConfig,
source: 'programmatic',
id: response.routeId || '',
enabled: enabled ?? true,
overridden: false,
storedRouteId: response.storedRouteId,
origin: 'api',
});
}

View File

@@ -12,10 +12,12 @@ export type TDnsRecordStatus = 'valid' | 'missing' | 'invalid' | 'unchecked';
*/
export interface IEmailDomain {
id: string;
/** Fully qualified domain name (e.g. 'example.com'). */
/** Fully qualified email domain name (e.g. 'example.com' or 'mail.example.com'). */
domain: string;
/** ID of the linked dcrouter DNS domain — determines how DNS records are managed. */
linkedDomainId: string;
/** Optional subdomain prefix (e.g. 'mail' for mail.example.com). Empty/undefined = bare domain. */
subdomain?: string;
/** DKIM configuration and key state. */
dkim: IEmailDomainDkim;
/** Optional per-domain rate limits. */

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 {
route: IDcRouterRouteConfig;
source: 'hardcoded' | 'programmatic';
id: string;
enabled: boolean;
overridden: boolean;
storedRouteId?: string;
origin: 'config' | 'email' | 'dns' | 'api';
createdAt?: number;
updatedAt?: number;
metadata?: IRouteMetadata;
}
/**
* A warning generated during route merge/startup.
* A warning generated during route startup/apply.
*/
export interface IRouteWarning {
type: 'disabled-hardcoded' | 'disabled-programmatic' | 'orphaned-override';
type: 'disabled-route';
routeName: 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;
route: IDcRouterRouteConfig;
enabled: boolean;
createdAt: number;
updatedAt: number;
createdBy: string;
origin: 'config' | 'email' | 'dns' | 'api';
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
*/

View File

@@ -143,6 +143,14 @@ export interface IHealthStatus {
version?: string;
}
export interface IDomainActivity {
domain: string;
bytesInPerSecond: number;
bytesOutPerSecond: number;
activeConnections: number;
routeCount: number;
}
export interface INetworkMetrics {
totalBandwidth: {
in: number;
@@ -156,12 +164,21 @@ export interface INetworkMetrics {
connectionDetails: IConnectionDetails[];
topEndpoints: Array<{
endpoint: string;
requests: number;
connections: number;
bandwidth: {
in: number;
out: number;
};
}>;
topEndpointsByBandwidth: Array<{
endpoint: string;
connections: number;
bandwidth: {
in: number;
out: number;
};
}>;
domainActivity: IDomainActivity[];
throughputHistory?: Array<{ timestamp: number; in: number; out: number }>;
requestsPerSecond?: number;
requestsTotal?: number;

View File

@@ -148,3 +148,31 @@ export interface IReq_SyncDomain extends plugins.typedrequestInterfaces.implemen
message?: string;
};
}
/**
* Migrate a domain between dcrouter-hosted and provider-managed (or between providers).
* Records are transferred to the target and the domain source/providerId are updated.
*/
export interface IReq_MigrateDomain extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_MigrateDomain
> {
method: 'migrateDomain';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
/** Target source type. */
targetSource: import('../data/domain.js').TDomainSource;
/** Required when targetSource is 'provider'. */
targetProviderId?: string;
/** When migrating to a provider: delete all existing records at the provider first. */
deleteExistingProviderRecords?: boolean;
};
response: {
success: boolean;
/** Number of records migrated. */
recordsMigrated?: number;
message?: string;
};
}

View File

@@ -55,6 +55,8 @@ export interface IReq_CreateEmailDomain extends plugins.typedrequestInterfaces.i
apiToken?: string;
/** ID of the existing dcrouter DNS domain to link to. */
linkedDomainId: string;
/** Optional subdomain prefix (e.g. 'mail' for mail.example.com). Leave empty for bare domain. */
subdomain?: string;
/** DKIM selector (default: 'default'). */
dkimSelector?: string;
/** RSA key size (default: 2048). */

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<
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<
plugins.typedrequestInterfaces.ITypedRequest,
@@ -43,13 +43,13 @@ export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.impleme
};
response: {
success: boolean;
storedRouteId?: string;
routeId?: string;
message?: string;
};
}
/**
* Update a programmatic route.
* Update a route.
*/
export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR<
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<
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).
*/
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.
* Toggle a route on/off by id.
*/
export interface IReq_ToggleRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,

View File

@@ -180,5 +180,9 @@ export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.imp
requestsPerSecond: number;
requestsTotal: number;
backends?: statsInterfaces.IBackendInfo[];
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
domainActivity: statsInterfaces.IDomainActivity[];
frontendProtocols?: statsInterfaces.IProtocolDistribution | null;
backendProtocols?: statsInterfaces.IProtocolDistribution | null;
};
}

View File

@@ -92,6 +92,34 @@ export async function createMigrationRunner(
'info',
`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;

View File

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

View File

@@ -52,7 +52,9 @@ export interface INetworkState {
throughputRate: { bytesInPerSecond: number; bytesOutPerSecond: number };
totalBytes: { in: number; out: number };
topIPs: Array<{ ip: string; count: number }>;
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
throughputByIP: Array<{ ip: string; in: number; out: number }>;
domainActivity: interfaces.data.IDomainActivity[];
throughputHistory: Array<{ timestamp: number; in: number; out: number }>;
requestsPerSecond: number;
requestsTotal: number;
@@ -160,7 +162,9 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
totalBytes: { in: 0, out: 0 },
topIPs: [],
topIPsByBandwidth: [],
throughputByIP: [],
domainActivity: [],
throughputHistory: [],
requestsPerSecond: 0,
requestsTotal: 0,
@@ -518,14 +522,13 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
});
// Get network stats for throughput and IP data
const networkStatsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest(
'/typedrequest',
'getNetworkStats'
);
const networkStatsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetNetworkStats
>('/typedrequest', 'getNetworkStats');
const networkStatsResponse = await networkStatsRequest.fire({
identity: context.identity,
}) as any;
});
// Use the connections data for the connection list
// and network stats for throughput and IP analytics
@@ -552,7 +555,9 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
? { in: networkStatsResponse.totalDataTransferred.bytesIn, out: networkStatsResponse.totalDataTransferred.bytesOut }
: { in: 0, out: 0 },
topIPs: networkStatsResponse.topIPs || [],
topIPsByBandwidth: networkStatsResponse.topIPsByBandwidth || [],
throughputByIP: networkStatsResponse.throughputByIP || [],
domainActivity: networkStatsResponse.domainActivity || [],
throughputHistory: networkStatsResponse.throughputHistory || [],
requestsPerSecond: networkStatsResponse.requestsPerSecond || 0,
requestsTotal: networkStatsResponse.requestsTotal || 0,
@@ -1887,6 +1892,32 @@ export const syncDomainAction = domainsStatePart.createAction<{ id: string }>(
},
);
export const migrateDomainAction = domainsStatePart.createAction<{
id: string;
targetSource: interfaces.data.TDomainSource;
targetProviderId?: string;
deleteExistingProviderRecords?: boolean;
}>(
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_MigrateDomain
>('/typedrequest', 'migrateDomain');
const response = await request.fire({ identity: context.identity!, ...dataArg });
if (!response.success) {
return { ...statePartArg.getState()!, error: response.message || 'Migration failed' };
}
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Migration failed',
};
}
},
);
export const createDnsRecordAction = domainsStatePart.createAction<{
domainId: string;
name: string;
@@ -2188,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
// ============================================================================
@@ -2422,6 +2401,7 @@ export const fetchEmailDomainsAction = emailDomainsStatePart.createAction(
export const createEmailDomainAction = emailDomainsStatePart.createAction<{
linkedDomainId: string;
subdomain?: string;
dkimSelector?: string;
dkimKeySize?: number;
rotateKeys?: boolean;
@@ -2622,67 +2602,52 @@ async function dispatchCombinedRefreshActionInner() {
if (combinedResponse.metrics.network && currentView === 'network') {
const network = combinedResponse.metrics.network;
const connectionsByIP: { [ip: string]: number } = {};
// Convert connection details to IP counts
// Build connectionsByIP from connectionDetails (now populated with real per-IP data)
network.connectionDetails.forEach(conn => {
connectionsByIP[conn.remoteAddress] = (connectionsByIP[conn.remoteAddress] || 0) + 1;
});
// Fetch detailed connections for the network view
try {
const connectionsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetActiveConnections
>('/typedrequest', 'getActiveConnections');
const connectionsResponse = await connectionsRequest.fire({
identity: context.identity,
});
// Build connections from connectionDetails (real per-IP aggregates)
const connections: interfaces.data.IConnectionInfo[] = network.connectionDetails.map((conn, i) => ({
id: `ip-${conn.remoteAddress}`,
remoteAddress: conn.remoteAddress,
localAddress: 'server',
startTime: conn.startTime,
protocol: conn.protocol as any,
state: conn.state as any,
bytesReceived: conn.bytesIn,
bytesSent: conn.bytesOut,
}));
networkStatePart.setState({
...networkStatePart.getState()!,
connections: connectionsResponse.connections,
connectionsByIP,
throughputRate: {
bytesInPerSecond: network.totalBandwidth.in,
bytesOutPerSecond: network.totalBandwidth.out
},
totalBytes: network.totalBytes || { in: 0, out: 0 },
topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.requests })),
throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })),
throughputHistory: network.throughputHistory || [],
requestsPerSecond: network.requestsPerSecond || 0,
requestsTotal: network.requestsTotal || 0,
backends: network.backends || [],
frontendProtocols: network.frontendProtocols || null,
backendProtocols: network.backendProtocols || null,
lastUpdated: Date.now(),
isLoading: false,
error: null,
});
} catch (error: unknown) {
console.error('Failed to fetch connections:', error);
networkStatePart.setState({
...networkStatePart.getState()!,
connections: [],
connectionsByIP,
throughputRate: {
bytesInPerSecond: network.totalBandwidth.in,
bytesOutPerSecond: network.totalBandwidth.out
},
totalBytes: network.totalBytes || { in: 0, out: 0 },
topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.requests })),
throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })),
throughputHistory: network.throughputHistory || [],
requestsPerSecond: network.requestsPerSecond || 0,
requestsTotal: network.requestsTotal || 0,
backends: network.backends || [],
frontendProtocols: network.frontendProtocols || null,
backendProtocols: network.backendProtocols || null,
lastUpdated: Date.now(),
isLoading: false,
error: null,
});
}
networkStatePart.setState({
...networkStatePart.getState()!,
connections,
connectionsByIP,
throughputRate: {
bytesInPerSecond: network.totalBandwidth.in,
bytesOutPerSecond: network.totalBandwidth.out,
},
totalBytes: network.totalBytes || { in: 0, out: 0 },
topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.connections })),
topIPsByBandwidth: (network.topEndpointsByBandwidth || []).map(e => ({
ip: e.endpoint,
count: e.connections,
bwIn: e.bandwidth?.in || 0,
bwOut: e.bandwidth?.out || 0,
})),
throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })),
domainActivity: network.domainActivity || [],
throughputHistory: network.throughputHistory || [],
requestsPerSecond: network.requestsPerSecond || 0,
requestsTotal: network.requestsTotal || 0,
backends: network.backends || [],
frontendProtocols: network.frontendProtocols || null,
backendProtocols: network.backendProtocols || null,
lastUpdated: Date.now(),
isLoading: false,
error: null,
});
}
// Refresh certificate data if on Domains > Certificates subview

View File

@@ -149,6 +149,15 @@ export class OpsViewDomains extends DeesElement {
});
},
},
{
name: 'Migrate',
iconName: 'lucide:arrow-right-left',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const domain = actionData.item as interfaces.data.IDomain;
await this.showMigrateDialog(domain);
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
@@ -308,6 +317,94 @@ export class OpsViewDomains extends DeesElement {
});
}
private async showMigrateDialog(domain: interfaces.data.IDomain) {
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
const providers = this.domainsState.providers;
// Build target options based on current source
const targetOptions: { option: string; key: string }[] = [];
for (const p of providers) {
// Skip current source
if (p.builtIn && domain.source === 'dcrouter') continue;
if (!p.builtIn && domain.source === 'provider' && domain.providerId === p.id) continue;
const label = p.builtIn ? 'DcRouter (self)' : `${p.name} (${p.type})`;
const key = p.builtIn ? 'dcrouter' : `provider:${p.id}`;
targetOptions.push({ option: label, key });
}
if (targetOptions.length === 0) {
DeesToast.show({
message: 'No migration targets available. Add a DNS provider first.',
type: 'warning',
duration: 3000,
});
return;
}
const currentLabel = domain.source === 'dcrouter'
? 'DcRouter (self)'
: providers.find((p) => p.id === domain.providerId)?.name || 'Provider';
DeesModal.createAndShow({
heading: `Migrate: ${domain.name}`,
content: html`
<dees-form>
<dees-input-text
.key=${'currentSource'}
.label=${'Current source'}
.value=${currentLabel}
.disabled=${true}
></dees-input-text>
<dees-input-dropdown
.key=${'target'}
.label=${'Migrate to'}
.description=${'Select the target DNS management'}
.options=${targetOptions}
.required=${true}
></dees-input-dropdown>
<dees-input-checkbox
.key=${'deleteExisting'}
.label=${'Delete existing records at provider first'}
.description=${'Removes all records at the provider before pushing migrated records'}
.value=${true}
></dees-input-checkbox>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', action: async (m: any) => m.destroy() },
{
name: 'Migrate',
action: async (m: any) => {
const form = m.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const targetKey = typeof data.target === 'object' ? data.target.key : data.target;
if (!targetKey) return;
let targetSource: interfaces.data.TDomainSource;
let targetProviderId: string | undefined;
if (targetKey === 'dcrouter') {
targetSource = 'dcrouter';
} else {
targetSource = 'provider';
targetProviderId = targetKey.replace('provider:', '');
}
await appstate.domainsStatePart.dispatchAction(appstate.migrateDomainAction, {
id: domain.id,
targetSource,
targetProviderId,
deleteExistingProviderRecords: targetSource === 'provider' ? Boolean(data.deleteExisting) : false,
});
DeesToast.show({ message: `Domain ${domain.name} migrated successfully`, type: 'success', duration: 3000 });
m.destroy();
},
},
],
});
}
private async deleteDomain(domain: interfaces.data.IDomain) {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({

View File

@@ -276,6 +276,11 @@ export class OpsViewEmailDomains extends DeesElement {
.options=${domainOptions}
.required=${true}
></dees-input-dropdown>
<dees-input-text
.key=${'subdomain'}
.label=${'Subdomain'}
.description=${'Leave empty for bare domain, e.g. "mail" for mail.example.com'}
></dees-input-text>
<dees-input-text
.key=${'dkimSelector'}
.label=${'DKIM Selector'}
@@ -316,10 +321,12 @@ export class OpsViewEmailDomains extends DeesElement {
? parseInt(data.dkimKeySize.key, 10)
: parseInt(data.dkimKeySize || '2048', 10);
const subdomain = data.subdomain?.trim() || undefined;
await appstate.emailDomainsStatePart.dispatchAction(
appstate.createEmailDomainAction,
{
linkedDomainId,
subdomain,
dkimSelector: data.dkimSelector || 'default',
dkimKeySize: keySize,
rotateKeys: Boolean(data.rotateKeys),

View File

@@ -10,22 +10,6 @@ declare global {
}
}
interface INetworkRequest {
id: string;
timestamp: number;
method: string;
url: string;
hostname: string;
port: number;
protocol: 'http' | 'https' | 'tcp' | 'udp';
statusCode?: number;
duration: number;
bytesIn: number;
bytesOut: number;
remoteIp: string;
route?: string;
}
@customElement('ops-view-network-activity')
export class OpsViewNetworkActivity extends DeesElement {
/** How far back the traffic chart shows */
@@ -42,9 +26,6 @@ export class OpsViewNetworkActivity extends DeesElement {
accessor networkState = appstate.networkStatePart.getState()!;
@state()
accessor networkRequests: INetworkRequest[] = [];
@state()
accessor trafficDataIn: Array<{ x: string | number; y: number }> = [];
@@ -314,108 +295,21 @@ export class OpsViewNetworkActivity extends DeesElement {
<!-- Protocol Distribution Charts -->
${this.renderProtocolCharts()}
<!-- Top IPs Section -->
<!-- Top IPs by Connection Count -->
${this.renderTopIPs()}
<!-- Top IPs by Bandwidth -->
${this.renderTopIPsByBandwidth()}
<!-- Domain Activity -->
${this.renderDomainActivity()}
<!-- Backend Protocols Section -->
${this.renderBackendProtocols()}
<!-- Requests Table -->
<dees-table
.data=${this.networkRequests}
.rowKey=${'id'}
.highlightUpdates=${'flash'}
.displayFunction=${(req: INetworkRequest) => ({
Time: new Date(req.timestamp).toLocaleTimeString(),
Protocol: html`<span class="protocolBadge ${req.protocol}">${req.protocol.toUpperCase()}</span>`,
Method: req.method,
'Host:Port': `${req.hostname}:${req.port}`,
Path: this.truncateUrl(req.url),
Status: this.renderStatus(req.statusCode),
Duration: `${req.duration}ms`,
'In/Out': `${this.formatBytes(req.bytesIn)} / ${this.formatBytes(req.bytesOut)}`,
'Remote IP': req.remoteIp,
})}
.dataActions=${[
{
name: 'View Details',
iconName: 'fa:magnifyingGlass',
type: ['inRow', 'doubleClick', 'contextmenu'],
actionFunc: async (actionData) => {
await this.showRequestDetails(actionData.item);
}
}
]}
heading1="Recent Network Activity"
heading2="Recent network requests"
searchable
.showColumnFilters=${true}
.pagination=${true}
.paginationSize=${50}
dataName="request"
></dees-table>
</div>
`;
}
private async showRequestDetails(request: INetworkRequest) {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: 'Request Details',
content: html`
<div style="padding: 20px;">
<dees-dataview-codebox
.heading=${'Request Information'}
progLang="json"
.codeToDisplay=${JSON.stringify({
id: request.id,
timestamp: new Date(request.timestamp).toISOString(),
protocol: request.protocol,
method: request.method,
url: request.url,
hostname: request.hostname,
port: request.port,
statusCode: request.statusCode,
duration: `${request.duration}ms`,
bytesIn: request.bytesIn,
bytesOut: request.bytesOut,
remoteIp: request.remoteIp,
route: request.route,
}, null, 2)}
></dees-dataview-codebox>
</div>
`,
menuOptions: [
{
name: 'Copy Request ID',
iconName: 'lucide:Copy',
action: async () => {
await navigator.clipboard.writeText(request.id);
}
}
]
});
}
private renderStatus(statusCode?: number): TemplateResult {
if (!statusCode) {
return html`<span class="statusBadge warning">N/A</span>`;
}
const statusClass = statusCode >= 200 && statusCode < 300 ? 'success' :
statusCode >= 400 ? 'error' : 'warning';
return html`<span class="statusBadge ${statusClass}">${statusCode}</span>`;
}
private truncateUrl(url: string, maxLength = 50): string {
if (url.length <= maxLength) return url;
return url.substring(0, maxLength - 3) + '...';
}
private formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
@@ -619,6 +513,66 @@ export class OpsViewNetworkActivity extends DeesElement {
`;
}
private renderTopIPsByBandwidth(): TemplateResult {
if (!this.networkState.topIPsByBandwidth || this.networkState.topIPsByBandwidth.length === 0) {
return html``;
}
return html`
<dees-table
.data=${this.networkState.topIPsByBandwidth}
.rowKey=${'ip'}
.highlightUpdates=${'flash'}
.displayFunction=${(ipData: { ip: string; count: number; bwIn: number; bwOut: number }) => {
return {
'IP Address': ipData.ip,
'Bandwidth In': this.formatBitsPerSecond(ipData.bwIn),
'Bandwidth Out': this.formatBitsPerSecond(ipData.bwOut),
'Total Bandwidth': this.formatBitsPerSecond(ipData.bwIn + ipData.bwOut),
'Connections': ipData.count,
};
}}
heading1="Top IPs by Bandwidth"
heading2="IPs with highest throughput"
searchable
.showColumnFilters=${true}
.pagination=${false}
dataName="ip"
></dees-table>
`;
}
private renderDomainActivity(): TemplateResult {
if (!this.networkState.domainActivity || this.networkState.domainActivity.length === 0) {
return html``;
}
return html`
<dees-table
.data=${this.networkState.domainActivity}
.rowKey=${'domain'}
.highlightUpdates=${'flash'}
.displayFunction=${(item: interfaces.data.IDomainActivity) => {
const totalBytesPerMin = (item.bytesInPerSecond + item.bytesOutPerSecond) * 60;
return {
'Domain': item.domain,
'Throughput In': this.formatBitsPerSecond(item.bytesInPerSecond),
'Throughput Out': this.formatBitsPerSecond(item.bytesOutPerSecond),
'Transferred / min': this.formatBytes(totalBytesPerMin),
'Connections': item.activeConnections,
'Routes': item.routeCount,
};
}}
heading1="Domain Activity"
heading2="Per-domain network activity aggregated from route metrics"
searchable
.showColumnFilters=${true}
.pagination=${false}
dataName="domain"
></dees-table>
`;
}
private renderBackendProtocols(): TemplateResult {
const backends = this.networkState.backends;
if (!backends || backends.length === 0) {
@@ -730,25 +684,6 @@ export class OpsViewNetworkActivity extends DeesElement {
this.requestsPerSecHistory.shift();
}
// Reassign unconditionally so dees-table's flash diff can compare per-cell
// values against the previous snapshot. Row identity is preserved via
// rowKey='id', so DOM nodes are reused across ticks.
this.networkRequests = this.networkState.connections.map((conn) => ({
id: conn.id,
timestamp: conn.startTime,
method: 'GET', // Default method for proxy connections
url: '/',
hostname: conn.remoteAddress,
port: conn.protocol === 'https' ? 443 : 80,
protocol: conn.protocol === 'https' || conn.protocol === 'http' ? conn.protocol : 'tcp',
statusCode: conn.state === 'connected' ? 200 : undefined,
duration: Date.now() - conn.startTime,
bytesIn: conn.bytesReceived,
bytesOut: conn.bytesSent,
remoteIp: conn.remoteAddress,
route: 'proxy',
}));
// Load server-side throughput history into chart (once)
if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) {
this.loadThroughputHistory();

View File

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