feat(routes,email): persist system DNS routes with runtime hydration and add reusable email ops DNS helpers

This commit is contained in:
2026-04-15 19:59:04 +00:00
parent e0386beb15
commit 39f449cbe4
24 changed files with 1221 additions and 2525 deletions

View File

@@ -1,5 +1,12 @@
# Changelog # Changelog
## 2026-04-15 - 13.19.0 - feat(routes,email)
persist system DNS routes with runtime hydration and add reusable email ops DNS helpers
- Persist seeded DNS-over-HTTPS routes with stable system keys and hydrate socket handlers at runtime instead of treating them as runtime-only routes
- Restrict system-managed routes to toggle-only operations across the route manager, Ops API, and web UI while returning explicit mutation errors
- Add a shared email DNS record builder and cover email queue operations and handler behavior with new tests
## 2026-04-14 - 13.18.0 - feat(email) ## 2026-04-14 - 13.18.0 - feat(email)
add persistent smartmta storage and runtime-managed email domain syncing add persistent smartmta storage and runtime-managed email domain syncing

View File

@@ -27,8 +27,7 @@
"@git.zone/tsrun": "^2.0.2", "@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.6.3", "@git.zone/tstest": "^3.6.3",
"@git.zone/tswatch": "^3.3.2", "@git.zone/tswatch": "^3.3.2",
"@types/node": "^25.6.0", "@types/node": "^25.6.0"
"typescript": "^6.0.2"
}, },
"dependencies": { "dependencies": {
"@api.global/typedrequest": "^3.3.0", "@api.global/typedrequest": "^3.3.0",

1799
readme.md

File diff suppressed because it is too large Load Diff

View File

@@ -40,7 +40,7 @@ const clearTestState = async () => {
} }
}; };
tap.test('RouteConfigManager applies runtime DoH routes without persisting them', async () => { tap.test('RouteConfigManager persists DoH system routes and hydrates runtime socket handlers', async () => {
await testDbPromise; await testDbPromise;
await clearTestState(); await clearTestState();
@@ -64,15 +64,24 @@ tap.test('RouteConfigManager applies runtime DoH routes without persisting them'
undefined, undefined,
undefined, undefined,
undefined, undefined,
() => (dcRouter as any).generateDnsRoutes(), undefined,
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
); );
await routeManager.initialize([], [], []); await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
await routeManager.applyRoutes();
const persistedRoutes = await RouteDoc.findAll(); const persistedRoutes = await RouteDoc.findAll();
expect(persistedRoutes.length).toEqual(0); expect(persistedRoutes.length).toEqual(2);
expect(appliedRoutes.length).toEqual(2); expect(persistedRoutes.every((route) => route.origin === 'dns')).toEqual(true);
expect((await RouteDoc.findByName('dns-over-https-dns-query'))?.systemKey).toEqual('dns:dns-over-https-dns-query');
expect((await RouteDoc.findByName('dns-over-https-resolve'))?.systemKey).toEqual('dns:dns-over-https-resolve');
const mergedRoutes = routeManager.getMergedRoutes().routes;
expect(mergedRoutes.length).toEqual(2);
expect(mergedRoutes.every((route) => route.origin === 'dns')).toEqual(true);
expect(mergedRoutes.every((route) => route.systemKey?.startsWith('dns:'))).toEqual(true);
expect(appliedRoutes.length).toEqual(1);
for (const routeSet of appliedRoutes) { for (const routeSet of appliedRoutes) {
const dnsQueryRoute = routeSet.find((route) => route.name === 'dns-over-https-dns-query'); const dnsQueryRoute = routeSet.find((route) => route.name === 'dns-over-https-dns-query');
@@ -85,10 +94,17 @@ tap.test('RouteConfigManager applies runtime DoH routes without persisting them'
} }
}); });
tap.test('RouteConfigManager removes stale persisted DoH socket-handler routes on startup', async () => { tap.test('RouteConfigManager backfills existing DoH system routes by name without duplicating them', async () => {
await testDbPromise; await testDbPromise;
await clearTestState(); await clearTestState();
const dcRouter = new DcRouter({
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
dnsScopes: ['example.com'],
smartProxyConfig: { routes: [] },
dbConfig: { enabled: false },
});
const staleDnsQueryRoute = new RouteDoc(); const staleDnsQueryRoute = new RouteDoc();
staleDnsQueryRoute.id = 'stale-doh-query'; staleDnsQueryRoute.id = 'stale-doh-query';
staleDnsQueryRoute.route = { staleDnsQueryRoute.route = {
@@ -109,47 +125,6 @@ tap.test('RouteConfigManager removes stale persisted DoH socket-handler routes o
staleDnsQueryRoute.origin = 'dns'; staleDnsQueryRoute.origin = 'dns';
await staleDnsQueryRoute.save(); await staleDnsQueryRoute.save();
const staleResolveRoute = new RouteDoc();
staleResolveRoute.id = 'stale-doh-resolve';
staleResolveRoute.route = {
name: 'dns-over-https-resolve',
match: {
ports: [443],
domains: ['ns1.example.com'],
path: '/resolve',
},
action: {
type: 'socket-handler' as any,
} as any,
};
staleResolveRoute.enabled = true;
staleResolveRoute.createdAt = Date.now();
staleResolveRoute.updatedAt = Date.now();
staleResolveRoute.createdBy = 'test';
staleResolveRoute.origin = 'dns';
await staleResolveRoute.save();
const validRoute = new RouteDoc();
validRoute.id = 'valid-forward-route';
validRoute.route = {
name: 'valid-forward-route',
match: {
ports: [443],
domains: ['app.example.com'],
},
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
tls: { mode: 'terminate' as const },
},
} as any;
validRoute.enabled = true;
validRoute.createdAt = Date.now();
validRoute.updatedAt = Date.now();
validRoute.createdBy = 'test';
validRoute.origin = 'api';
await validRoute.save();
const appliedRoutes: any[][] = []; const appliedRoutes: any[][] = [];
const smartProxy = { const smartProxy = {
updateRoutes: async (routes: any[]) => { updateRoutes: async (routes: any[]) => {
@@ -157,19 +132,76 @@ tap.test('RouteConfigManager removes stale persisted DoH socket-handler routes o
}, },
}; };
const routeManager = new RouteConfigManager(() => smartProxy as any); const routeManager = new RouteConfigManager(
await routeManager.initialize([], [], []); () => smartProxy as any,
undefined,
expect((await RouteDoc.findByName('dns-over-https-dns-query'))).toEqual(null); undefined,
expect((await RouteDoc.findByName('dns-over-https-resolve'))).toEqual(null); undefined,
undefined,
undefined,
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
);
await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
const remainingRoutes = await RouteDoc.findAll(); const remainingRoutes = await RouteDoc.findAll();
expect(remainingRoutes.length).toEqual(1); expect(remainingRoutes.length).toEqual(2);
expect(remainingRoutes[0].route.name).toEqual('valid-forward-route'); expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-dns-query').length).toEqual(1);
expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-resolve').length).toEqual(1);
const queryRoute = await RouteDoc.findByName('dns-over-https-dns-query');
expect(queryRoute?.id).toEqual('stale-doh-query');
expect(queryRoute?.systemKey).toEqual('dns:dns-over-https-dns-query');
const resolveRoute = await RouteDoc.findByName('dns-over-https-resolve');
expect(resolveRoute?.systemKey).toEqual('dns:dns-over-https-resolve');
expect(appliedRoutes.length).toEqual(1); expect(appliedRoutes.length).toEqual(1);
expect(appliedRoutes[0].length).toEqual(1); expect(appliedRoutes[0].length).toEqual(2);
expect(appliedRoutes[0][0].name).toEqual('valid-forward-route'); expect(appliedRoutes[0].every((route) => typeof route.action.socketHandler === 'function')).toEqual(true);
});
tap.test('RouteConfigManager only allows toggling system routes', async () => {
await testDbPromise;
await clearTestState();
const smartProxy = {
updateRoutes: async (_routes: any[]) => {
return;
},
};
const routeManager = new RouteConfigManager(() => smartProxy as any);
await routeManager.initialize([
{
name: 'system-config-route',
match: {
ports: [443],
domains: ['app.example.com'],
},
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8443 }],
tls: { mode: 'terminate' as const },
},
} as any,
], [], []);
const systemRoute = routeManager.getMergedRoutes().routes.find((route) => route.route.name === 'system-config-route');
expect(systemRoute).toBeDefined();
const updateResult = await routeManager.updateRoute(systemRoute!.id, {
route: { name: 'renamed-system-route' } as any,
});
expect(updateResult.success).toEqual(false);
expect(updateResult.message).toEqual('System routes are managed by the system and can only be toggled');
const deleteResult = await routeManager.deleteRoute(systemRoute!.id);
expect(deleteResult.success).toEqual(false);
expect(deleteResult.message).toEqual('System routes are managed by the system and cannot be deleted');
const toggleResult = await routeManager.toggleRoute(systemRoute!.id, false);
expect(toggleResult.success).toEqual(true);
expect((await RouteDoc.findById(systemRoute!.id))?.enabled).toEqual(false);
}); });
tap.test('DnsManager warning keeps dnsNsDomains in scope', async () => { tap.test('DnsManager warning keeps dnsNsDomains in scope', async () => {

View File

@@ -0,0 +1,65 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { buildEmailDnsRecords } from '../ts/email/index.js';
tap.test('buildEmailDnsRecords uses the configured mail hostname for MX and includes DKIM when provided', async () => {
const records = buildEmailDnsRecords({
domain: 'example.com',
hostname: 'mail.example.com',
selector: 'selector1',
dkimValue: 'v=DKIM1; h=sha256; k=rsa; p=abc123',
statuses: {
mx: 'valid',
spf: 'missing',
dkim: 'valid',
dmarc: 'unchecked',
},
});
expect(records).toEqual([
{
type: 'MX',
name: 'example.com',
value: '10 mail.example.com',
status: 'valid',
},
{
type: 'TXT',
name: 'example.com',
value: 'v=spf1 a mx ~all',
status: 'missing',
},
{
type: 'TXT',
name: 'selector1._domainkey.example.com',
value: 'v=DKIM1; h=sha256; k=rsa; p=abc123',
status: 'valid',
},
{
type: 'TXT',
name: '_dmarc.example.com',
value: 'v=DMARC1; p=none; rua=mailto:dmarc@example.com',
status: 'unchecked',
},
]);
});
tap.test('buildEmailDnsRecords omits DKIM when no value is provided', async () => {
const records = buildEmailDnsRecords({
domain: 'example.net',
hostname: 'smtp.example.net',
mxPriority: 20,
});
expect(records.map((record) => record.name)).toEqual([
'example.net',
'example.net',
'_dmarc.example.net',
]);
expect(records[0].value).toEqual('20 smtp.example.net');
});
tap.test('cleanup', async () => {
await tap.stopForcefully();
});
export default tap.start();

167
test/test.email-ops-api.ts Normal file
View File

@@ -0,0 +1,167 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { TypedRequest } from '@api.global/typedrequest';
import { DcRouter } from '../ts/index.js';
import * as interfaces from '../ts_interfaces/index.js';
const TEST_PORT = 3201;
const BASE_URL = `http://localhost:${TEST_PORT}/typedrequest`;
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
let removedQueueItemId: string | undefined;
let lastEnqueueArgs: any[] | undefined;
const queueItems = [
{
id: 'failed-email-1',
status: 'failed',
attempts: 3,
nextAttempt: new Date('2026-04-14T10:00:00.000Z'),
lastError: '550 mailbox unavailable',
processingMode: 'mta',
route: undefined,
createdAt: new Date('2026-04-14T09:00:00.000Z'),
processingResult: {
from: 'sender@example.com',
to: ['recipient@example.net'],
cc: ['copy@example.net'],
subject: 'Older message',
text: 'hello',
headers: { 'x-test': '1' },
getMessageId: () => 'message-older',
getAttachmentsSize: () => 64,
},
},
{
id: 'delivered-email-1',
status: 'delivered',
attempts: 1,
processingMode: 'mta',
route: undefined,
createdAt: new Date('2026-04-14T11:00:00.000Z'),
processingResult: {
email: {
from: 'fresh@example.com',
to: ['new@example.net'],
cc: [],
subject: 'Newest message',
},
html: '<p>newest</p>',
text: 'newest',
headers: { 'x-fresh': 'true' },
getMessageId: () => 'message-newer',
getAttachmentsSize: () => 0,
},
},
];
tap.test('should start DCRouter with OpsServer for email API tests', async () => {
testDcRouter = new DcRouter({
opsServerPort: TEST_PORT,
dbConfig: { enabled: false },
});
await testDcRouter.start();
testDcRouter.emailServer = {
getQueueItems: () => [...queueItems],
getQueueItem: (id: string) => queueItems.find((item) => item.id === id),
getQueueStats: () => ({
queueSize: 2,
status: {
pending: 0,
processing: 1,
failed: 1,
deferred: 1,
delivered: 1,
},
}),
deliveryQueue: {
enqueue: async (...args: any[]) => {
lastEnqueueArgs = args;
return 'resent-queue-id';
},
removeItem: async (id: string) => {
removedQueueItemId = id;
return true;
},
},
} as any;
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
});
tap.test('should login as admin for email API tests', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
BASE_URL,
'adminLoginWithUsernameAndPassword',
);
const response = await loginRequest.fire({
username: 'admin',
password: 'admin',
});
adminIdentity = response.identity;
expect(adminIdentity.jwt).toBeTruthy();
});
tap.test('should return queued emails through the email ops API', async () => {
const request = new TypedRequest<interfaces.requests.IReq_GetAllEmails>(BASE_URL, 'getAllEmails');
const response = await request.fire({
identity: adminIdentity,
});
expect(response.emails.map((email) => email.id)).toEqual(['delivered-email-1', 'failed-email-1']);
expect(response.emails[0].status).toEqual('delivered');
expect(response.emails[1].status).toEqual('bounced');
});
tap.test('should return email detail through the email ops API', async () => {
const request = new TypedRequest<interfaces.requests.IReq_GetEmailDetail>(BASE_URL, 'getEmailDetail');
const response = await request.fire({
identity: adminIdentity,
emailId: 'failed-email-1',
});
expect(response.email?.toList).toEqual(['recipient@example.net']);
expect(response.email?.cc).toEqual(['copy@example.net']);
expect(response.email?.rejectionReason).toEqual('550 mailbox unavailable');
expect(response.email?.headers).toEqual({ 'x-test': '1' });
});
tap.test('should expose queue status through the stats API', async () => {
const request = new TypedRequest<interfaces.requests.IReq_GetQueueStatus>(BASE_URL, 'getQueueStatus');
const response = await request.fire({
identity: adminIdentity,
});
expect(response.queues.length).toEqual(1);
expect(response.queues[0].size).toEqual(0);
expect(response.queues[0].processing).toEqual(1);
expect(response.queues[0].failed).toEqual(1);
expect(response.queues[0].retrying).toEqual(1);
expect(response.totalItems).toEqual(3);
});
tap.test('should resend failed email through the admin email ops API', async () => {
const request = new TypedRequest<interfaces.requests.IReq_ResendEmail>(BASE_URL, 'resendEmail');
const response = await request.fire({
identity: adminIdentity,
emailId: 'failed-email-1',
});
expect(response.success).toEqual(true);
expect(response.newQueueId).toEqual('resent-queue-id');
expect(removedQueueItemId).toEqual('failed-email-1');
expect(lastEnqueueArgs?.[0]).toEqual(queueItems[0].processingResult);
});
tap.test('should stop DCRouter after email API tests', async () => {
await testDcRouter.stop();
});
tap.test('cleanup', async () => {
await tap.stopForcefully();
});
export default tap.start();

View File

@@ -0,0 +1,107 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { EmailOpsHandler } from '../ts/opsserver/handlers/email-ops.handler.js';
import { StatsHandler } from '../ts/opsserver/handlers/stats.handler.js';
const createRouterStub = () => ({
addTypedHandler: (_handler: unknown) => {},
});
const queueItems = [
{
id: 'older-failed',
status: 'failed',
attempts: 3,
nextAttempt: new Date('2026-04-14T10:00:00.000Z'),
lastError: '550 mailbox unavailable',
createdAt: new Date('2026-04-14T09:00:00.000Z'),
processingResult: {
from: 'sender@example.com',
to: ['recipient@example.net'],
cc: ['copy@example.net'],
subject: 'Older message',
text: 'hello',
headers: { 'x-test': '1' },
getMessageId: () => 'message-older',
getAttachmentsSize: () => 64,
},
},
{
id: 'newer-delivered',
status: 'delivered',
attempts: 1,
createdAt: new Date('2026-04-14T11:00:00.000Z'),
processingResult: {
email: {
from: 'fresh@example.com',
to: ['new@example.net'],
cc: [],
subject: 'Newest message',
},
html: '<p>newest</p>',
text: 'newest',
headers: { 'x-fresh': 'true' },
getMessageId: () => 'message-newer',
getAttachmentsSize: () => 0,
},
},
];
tap.test('EmailOpsHandler maps queue items using public email server APIs', async () => {
const opsHandler = new EmailOpsHandler({
viewRouter: createRouterStub(),
adminRouter: createRouterStub(),
dcRouterRef: {
emailServer: {
getQueueItems: () => queueItems,
getQueueItem: (id: string) => queueItems.find((item) => item.id === id),
},
},
} as any);
const emails = (opsHandler as any).getAllQueueEmails();
expect(emails.map((email: any) => email.id)).toEqual(['newer-delivered', 'older-failed']);
expect(emails[0].status).toEqual('delivered');
expect(emails[1].status).toEqual('bounced');
expect(emails[0].messageId).toEqual('message-newer');
const detail = (opsHandler as any).getEmailDetail('older-failed');
expect(detail?.toList).toEqual(['recipient@example.net']);
expect(detail?.cc).toEqual(['copy@example.net']);
expect(detail?.rejectionReason).toEqual('550 mailbox unavailable');
expect(detail?.headers).toEqual({ 'x-test': '1' });
});
tap.test('StatsHandler reports queue status using public email server APIs', async () => {
const statsHandler = new StatsHandler({
viewRouter: createRouterStub(),
dcRouterRef: {
emailServer: {
getQueueStats: () => ({
queueSize: 2,
status: {
pending: 0,
processing: 1,
failed: 1,
deferred: 1,
delivered: 1,
},
}),
getQueueItems: () => queueItems,
},
},
} as any);
const queueStatus = await (statsHandler as any).getQueueStatus();
expect(queueStatus.pending).toEqual(0);
expect(queueStatus.active).toEqual(1);
expect(queueStatus.failed).toEqual(1);
expect(queueStatus.retrying).toEqual(1);
expect(queueStatus.items.map((item: any) => item.id)).toEqual(['newer-delivered', 'older-failed']);
expect(queueStatus.items[1].nextRetry).toEqual(new Date('2026-04-14T10:00:00.000Z').getTime());
});
tap.test('cleanup', async () => {
await tap.stopForcefully();
});
export default tap.start();

View File

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

View File

@@ -30,7 +30,8 @@ import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js'; import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
import { DnsManager } from './dns/manager.dns.js'; import { DnsManager } from './dns/manager.dns.js';
import { AcmeConfigManager } from './acme/manager.acme-config.js'; import { AcmeConfigManager } from './acme/manager.acme-config.js';
import { EmailDomainManager, SmartMtaStorageManager } from './email/index.js'; import { EmailDomainManager, SmartMtaStorageManager, buildEmailDnsRecords } from './email/index.js';
import type { IRoute } from '../ts_interfaces/data/route-management.js';
export interface IDcRouterOptions { export interface IDcRouterOptions {
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */ /** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
@@ -314,7 +315,8 @@ export class DcRouter {
// Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding // Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = []; private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = []; private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Runtime-only DoH routes. These carry live socket handlers and must never be persisted. private seedDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Live DoH routes used during SmartProxy bootstrap before RouteConfigManager re-applies stored routes.
private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = []; private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Environment access // Environment access
@@ -588,13 +590,15 @@ export class DcRouter {
this.tunnelManager.syncAllowedEdges(); this.tunnelManager.syncAllowedEdges();
} }
}, },
() => this.runtimeDnsRoutes, undefined,
(storedRoute: IRoute) => this.hydrateStoredRouteForRuntime(storedRoute),
); );
this.apiTokenManager = new ApiTokenManager(); this.apiTokenManager = new ApiTokenManager();
await this.apiTokenManager.initialize(); await this.apiTokenManager.initialize();
await this.routeConfigManager.initialize( await this.routeConfigManager.initialize(
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedEmailRoutes 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[],
); );
await this.targetProfileManager.normalizeAllRouteRefs(); await this.targetProfileManager.normalizeAllRouteRefs();
@@ -912,10 +916,12 @@ export class DcRouter {
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) }); logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
} }
this.seedDnsRoutes = [];
this.runtimeDnsRoutes = []; this.runtimeDnsRoutes = [];
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) { if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
this.runtimeDnsRoutes = this.generateDnsRoutes(); this.seedDnsRoutes = this.generateDnsRoutes({ includeSocketHandler: false });
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.runtimeDnsRoutes) }); this.runtimeDnsRoutes = this.generateDnsRoutes({ includeSocketHandler: true });
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) // Combined routes for SmartProxy bootstrap (before DB routes are loaded)
@@ -1338,19 +1344,20 @@ export class DcRouter {
/** /**
* Generate SmartProxy routes for DNS configuration * Generate SmartProxy routes for DNS configuration
*/ */
private generateDnsRoutes(): plugins.smartproxy.IRouteConfig[] { private generateDnsRoutes(options?: { includeSocketHandler?: boolean }): plugins.smartproxy.IRouteConfig[] {
if (!this.options.dnsNsDomains || this.options.dnsNsDomains.length === 0) { if (!this.options.dnsNsDomains || this.options.dnsNsDomains.length === 0) {
return []; return [];
} }
const includeSocketHandler = options?.includeSocketHandler !== false;
const dnsRoutes: plugins.smartproxy.IRouteConfig[] = []; const dnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Create routes for DNS-over-HTTPS paths // Create routes for DNS-over-HTTPS paths
const dohPaths = ['/dns-query', '/resolve']; const dohPaths = ['/dns-query', '/resolve'];
// Use the first nameserver domain for DoH routes // Use the first nameserver domain for DoH routes
const primaryNameserver = this.options.dnsNsDomains[0]; const primaryNameserver = this.options.dnsNsDomains[0];
for (const path of dohPaths) { for (const path of dohPaths) {
const dohRoute: plugins.smartproxy.IRouteConfig = { const dohRoute: plugins.smartproxy.IRouteConfig = {
name: `dns-over-https-${path.replace('/', '')}`, name: `dns-over-https-${path.replace('/', '')}`,
@@ -1359,18 +1366,42 @@ export class DcRouter {
domains: [primaryNameserver], domains: [primaryNameserver],
path: path path: path
}, },
action: { action: includeSocketHandler
type: 'socket-handler' as any, ? {
socketHandler: this.createDnsSocketHandler() type: 'socket-handler' as any,
} as any socketHandler: this.createDnsSocketHandler()
} as any
: {
type: 'socket-handler' as any,
} as any
}; };
dnsRoutes.push(dohRoute); dnsRoutes.push(dohRoute);
} }
return dnsRoutes; return dnsRoutes;
} }
private hydrateStoredRouteForRuntime(storedRoute: IRoute): plugins.smartproxy.IRouteConfig | undefined {
const routeName = storedRoute.route.name || '';
const isDohRoute = storedRoute.origin === 'dns'
&& storedRoute.route.action?.type === 'socket-handler'
&& routeName.startsWith('dns-over-https-');
if (!isDohRoute) {
return undefined;
}
return {
...storedRoute.route,
action: {
...storedRoute.route.action,
type: 'socket-handler' as any,
socketHandler: this.createDnsSocketHandler(),
} as any,
};
}
/** /**
* Check if a domain matches a pattern (including wildcard support) * Check if a domain matches a pattern (including wildcard support)
* @param domain The domain to check * @param domain The domain to check
@@ -1939,37 +1970,20 @@ export class DcRouter {
for (const domainConfig of internalDnsDomains) { for (const domainConfig of internalDnsDomains) {
const domain = domainConfig.domain; const domain = domainConfig.domain;
const ttl = domainConfig.dns?.internal?.ttl || 3600; const ttl = domainConfig.dns?.internal?.ttl || 3600;
const mxPriority = domainConfig.dns?.internal?.mxPriority || 10; const requiredRecords = buildEmailDnsRecords({
domain,
// MX record - points to the domain itself for email handling hostname: this.options.emailConfig.hostname,
records.push({ mxPriority: domainConfig.dns?.internal?.mxPriority,
name: domain, }).filter((record) => !record.name.includes('._domainkey.'));
type: 'MX',
value: `${mxPriority} ${domain}`, for (const record of requiredRecords) {
ttl records.push({
}); name: record.name,
type: record.type,
// SPF record - using sensible defaults value: record.value,
const spfRecord = 'v=spf1 a mx ~all'; ttl,
records.push({ });
name: domain, }
type: 'TXT',
value: spfRecord,
ttl
});
// DMARC record - using sensible defaults
const dmarcPolicy = 'none'; // Start with 'none' policy for monitoring
const dmarcEmail = `dmarc@${domain}`;
records.push({
name: `_dmarc.${domain}`,
type: 'TXT',
value: `v=DMARC1; p=${dmarcPolicy}; rua=mailto:${dmarcEmail}`,
ttl
});
// Note: DKIM records will be generated later when DKIM keys are available
// They require the DKIMCreator which is part of the email server
} }
logger.log('info', `Generated ${records.length} email DNS records for ${internalDnsDomains.length} internal-dns domains`); logger.log('info', `Generated ${records.length} email DNS records for ${internalDnsDomains.length} internal-dns domains`);

View File

@@ -14,6 +14,11 @@ import type { ReferenceResolver } from './classes.reference-resolver.js';
/** An IP allow entry: plain IP/CIDR or domain-scoped. */ /** An IP allow entry: plain IP/CIDR or domain-scoped. */
export type TIpAllowEntry = string | { ip: string; domains: string[] }; export type TIpAllowEntry = string | { ip: string; domains: string[] };
export interface IRouteMutationResult {
success: boolean;
message?: string;
}
/** /**
* Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine * Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine
* never receives rapid overlapping route updates that can churn UDP/QUIC listeners. * never receives rapid overlapping route updates that can churn UDP/QUIC listeners.
@@ -56,6 +61,7 @@ export class RouteConfigManager {
private referenceResolver?: ReferenceResolver, private referenceResolver?: ReferenceResolver,
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void, private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[], private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
) {} ) {}
/** Expose routes map for reference resolution lookups. */ /** Expose routes map for reference resolution lookups. */
@@ -63,6 +69,10 @@ export class RouteConfigManager {
return this.routes; return this.routes;
} }
public getRoute(id: string): IRoute | undefined {
return this.routes.get(id);
}
/** /**
* Load persisted routes, seed serializable config/email/dns routes, * Load persisted routes, seed serializable config/email/dns routes,
* compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy. * compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
@@ -94,6 +104,7 @@ export class RouteConfigManager {
id: route.id, id: route.id,
enabled: route.enabled, enabled: route.enabled,
origin: route.origin, origin: route.origin,
systemKey: route.systemKey,
createdAt: route.createdAt, createdAt: route.createdAt,
updatedAt: route.updatedAt, updatedAt: route.updatedAt,
metadata: route.metadata, metadata: route.metadata,
@@ -153,9 +164,21 @@ export class RouteConfigManager {
enabled?: boolean; enabled?: boolean;
metadata?: Partial<IRouteMetadata>; metadata?: Partial<IRouteMetadata>;
}, },
): Promise<boolean> { ): Promise<IRouteMutationResult> {
const stored = this.routes.get(id); const stored = this.routes.get(id);
if (!stored) return false; if (!stored) {
return { success: false, message: 'Route not found' };
}
const isToggleOnlyPatch = patch.enabled !== undefined
&& patch.route === undefined
&& patch.metadata === undefined;
if (stored.origin !== 'api' && !isToggleOnlyPatch) {
return {
success: false,
message: 'System routes are managed by the system and can only be toggled',
};
}
if (patch.route) { if (patch.route) {
const mergedAction = patch.route.action const mergedAction = patch.route.action
@@ -189,19 +212,29 @@ export class RouteConfigManager {
await this.persistRoute(stored); await this.persistRoute(stored);
await this.applyRoutes(); await this.applyRoutes();
return true; return { success: true };
} }
public async deleteRoute(id: string): Promise<boolean> { public async deleteRoute(id: string): Promise<IRouteMutationResult> {
if (!this.routes.has(id)) return false; const stored = this.routes.get(id);
if (!stored) {
return { success: false, message: 'Route not found' };
}
if (stored.origin !== 'api') {
return {
success: false,
message: 'System routes are managed by the system and cannot be deleted',
};
}
this.routes.delete(id); this.routes.delete(id);
const doc = await RouteDoc.findById(id); const doc = await RouteDoc.findById(id);
if (doc) await doc.delete(); if (doc) await doc.delete();
await this.applyRoutes(); await this.applyRoutes();
return true; return { success: true };
} }
public async toggleRoute(id: string, enabled: boolean): Promise<boolean> { public async toggleRoute(id: string, enabled: boolean): Promise<IRouteMutationResult> {
return this.updateRoute(id, { enabled }); return this.updateRoute(id, { enabled });
} }
@@ -217,29 +250,28 @@ export class RouteConfigManager {
seedRoutes: IDcRouterRouteConfig[], seedRoutes: IDcRouterRouteConfig[],
origin: 'config' | 'email' | 'dns', origin: 'config' | 'email' | 'dns',
): Promise<void> { ): Promise<void> {
if (seedRoutes.length === 0) return; const seedSystemKeys = new Set<string>();
const seedNames = new Set<string>(); const seedNames = new Set<string>();
let seeded = 0; let seeded = 0;
let updated = 0; let updated = 0;
for (const route of seedRoutes) { for (const route of seedRoutes) {
const name = route.name || ''; const name = route.name || '';
seedNames.add(name); if (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;
}
} }
const systemKey = this.buildSystemRouteKey(origin, route);
if (systemKey) {
seedSystemKeys.add(systemKey);
}
const existingId = this.findExistingSeedRouteId(origin, route, systemKey);
if (existingId) { if (existingId) {
// Update route config but preserve enabled state // Update route config but preserve enabled state
const existing = this.routes.get(existingId)!; const existing = this.routes.get(existingId)!;
existing.route = route; existing.route = route;
existing.systemKey = systemKey;
existing.updatedAt = Date.now(); existing.updatedAt = Date.now();
await this.persistRoute(existing); await this.persistRoute(existing);
updated++; updated++;
@@ -255,6 +287,7 @@ export class RouteConfigManager {
updatedAt: now, updatedAt: now,
createdBy: 'system', createdBy: 'system',
origin, origin,
systemKey,
}; };
this.routes.set(id, newRoute); this.routes.set(id, newRoute);
await this.persistRoute(newRoute); await this.persistRoute(newRoute);
@@ -265,7 +298,12 @@ export class RouteConfigManager {
// Delete stale routes: same origin but name not in current seed set // Delete stale routes: same origin but name not in current seed set
const staleIds: string[] = []; const staleIds: string[] = [];
for (const [id, r] of this.routes) { for (const [id, r] of this.routes) {
if (r.origin === origin && !seedNames.has(r.route.name || '')) { if (r.origin !== origin) continue;
const routeName = r.route.name || '';
const matchesSeedSystemKey = r.systemKey ? seedSystemKeys.has(r.systemKey) : false;
const matchesSeedName = routeName ? seedNames.has(routeName) : false;
if (!matchesSeedSystemKey && !matchesSeedName) {
staleIds.push(id); staleIds.push(id);
} }
} }
@@ -284,9 +322,39 @@ export class RouteConfigManager {
// Private: persistence // Private: persistence
// ========================================================================= // =========================================================================
private buildSystemRouteKey(
origin: 'config' | 'email' | 'dns',
route: IDcRouterRouteConfig,
): string | undefined {
const name = route.name?.trim();
if (!name) return undefined;
return `${origin}:${name}`;
}
private findExistingSeedRouteId(
origin: 'config' | 'email' | 'dns',
route: IDcRouterRouteConfig,
systemKey?: string,
): string | undefined {
const routeName = route.name || '';
for (const [id, storedRoute] of this.routes) {
if (storedRoute.origin !== origin) continue;
if (systemKey && storedRoute.systemKey === systemKey) {
return id;
}
if (storedRoute.route.name === routeName) {
return id;
}
}
return undefined;
}
private async loadRoutes(): Promise<void> { private async loadRoutes(): Promise<void> {
const docs = await RouteDoc.findAll(); const docs = await RouteDoc.findAll();
let prunedRuntimeRoutes = 0;
for (const doc of docs) { for (const doc of docs) {
if (!doc.id) continue; if (!doc.id) continue;
@@ -299,27 +367,15 @@ export class RouteConfigManager {
updatedAt: doc.updatedAt, updatedAt: doc.updatedAt,
createdBy: doc.createdBy, createdBy: doc.createdBy,
origin: doc.origin || 'api', origin: doc.origin || 'api',
systemKey: doc.systemKey,
metadata: doc.metadata, metadata: doc.metadata,
}; };
if (this.isPersistedRuntimeRoute(storedRoute)) {
await doc.delete();
prunedRuntimeRoutes++;
logger.log(
'warn',
`Removed persisted runtime-only route '${storedRoute.route.name || storedRoute.id}' (${storedRoute.id}) from RouteDoc`,
);
continue;
}
this.routes.set(doc.id, storedRoute); this.routes.set(doc.id, storedRoute);
} }
if (this.routes.size > 0) { if (this.routes.size > 0) {
logger.log('info', `Loaded ${this.routes.size} route(s) from database`); logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
} }
if (prunedRuntimeRoutes > 0) {
logger.log('info', `Pruned ${prunedRuntimeRoutes} persisted runtime-only route(s) from RouteDoc`);
}
} }
private async persistRoute(stored: IRoute): Promise<void> { private async persistRoute(stored: IRoute): Promise<void> {
@@ -330,6 +386,7 @@ export class RouteConfigManager {
existingDoc.updatedAt = stored.updatedAt; existingDoc.updatedAt = stored.updatedAt;
existingDoc.createdBy = stored.createdBy; existingDoc.createdBy = stored.createdBy;
existingDoc.origin = stored.origin; existingDoc.origin = stored.origin;
existingDoc.systemKey = stored.systemKey;
existingDoc.metadata = stored.metadata; existingDoc.metadata = stored.metadata;
await existingDoc.save(); await existingDoc.save();
} else { } else {
@@ -341,6 +398,7 @@ export class RouteConfigManager {
doc.updatedAt = stored.updatedAt; doc.updatedAt = stored.updatedAt;
doc.createdBy = stored.createdBy; doc.createdBy = stored.createdBy;
doc.origin = stored.origin; doc.origin = stored.origin;
doc.systemKey = stored.systemKey;
doc.metadata = stored.metadata; doc.metadata = stored.metadata;
await doc.save(); await doc.save();
} }
@@ -411,7 +469,7 @@ export class RouteConfigManager {
// Add all enabled routes with HTTP/3 and VPN augmentation // Add all enabled routes with HTTP/3 and VPN augmentation
for (const route of this.routes.values()) { for (const route of this.routes.values()) {
if (route.enabled) { if (route.enabled) {
enabledRoutes.push(this.prepareRouteForApply(route.route, route.id)); enabledRoutes.push(this.prepareStoredRouteForApply(route));
} }
} }
@@ -431,6 +489,11 @@ export class RouteConfigManager {
}); });
} }
private prepareStoredRouteForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig {
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
return this.prepareRouteForApply(hydratedRoute || storedRoute.route, storedRoute.id);
}
private prepareRouteForApply( private prepareRouteForApply(
route: plugins.smartproxy.IRouteConfig, route: plugins.smartproxy.IRouteConfig,
routeId?: string, routeId?: string,
@@ -465,12 +528,4 @@ export class RouteConfigManager {
}, },
}; };
} }
private isPersistedRuntimeRoute(storedRoute: IRoute): boolean {
const routeName = storedRoute.route.name || '';
const actionType = storedRoute.route.action?.type;
return (routeName.startsWith('dns-over-https-') && actionType === 'socket-handler')
|| (storedRoute.origin === 'dns' && actionType === 'socket-handler');
}
} }

View File

@@ -29,6 +29,9 @@ export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDo
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public origin!: 'config' | 'email' | 'dns' | 'api'; public origin!: 'config' | 'email' | 'dns' | 'api';
@plugins.smartdata.svDb()
public systemKey?: string;
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public metadata?: IRouteMetadata; public metadata?: IRouteMetadata;
@@ -51,4 +54,8 @@ export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDo
public static async findByOrigin(origin: 'config' | 'email' | 'dns' | 'api'): Promise<RouteDoc[]> { public static async findByOrigin(origin: 'config' | 'email' | 'dns' | 'api'): Promise<RouteDoc[]> {
return await RouteDoc.getInstances({ origin }); return await RouteDoc.getInstances({ origin });
} }
public static async findBySystemKey(systemKey: string): Promise<RouteDoc | null> {
return await RouteDoc.getInstance({ systemKey });
}
} }

View File

@@ -6,6 +6,7 @@ import { DomainDoc } from '../db/documents/classes.domain.doc.js';
import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js'; import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
import type { DnsManager } from '../dns/manager.dns.js'; import type { DnsManager } from '../dns/manager.dns.js';
import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js'; import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
import { buildEmailDnsRecords } from './email-dns-records.js';
/** /**
* EmailDomainManager — orchestrates email domain setup. * EmailDomainManager — orchestrates email domain setup.
@@ -181,34 +182,13 @@ export class EmailDomainManager {
} }
} }
const records: IEmailDnsRecord[] = [ return buildEmailDnsRecords({
{ domain,
type: 'MX', hostname,
name: domain, selector,
value: `10 ${hostname}`, dkimValue,
status: doc.dnsStatus.mx, statuses: doc.dnsStatus,
}, });
{
type: 'TXT',
name: domain,
value: 'v=spf1 a mx ~all',
status: doc.dnsStatus.spf,
},
{
type: 'TXT',
name: `${selector}._domainkey.${domain}`,
value: dkimValue,
status: doc.dnsStatus.dkim,
},
{
type: 'TXT',
name: `_dmarc.${domain}`,
value: `v=DMARC1; p=none; rua=mailto:dmarc@${domain}`,
status: doc.dnsStatus.dmarc,
},
];
return records;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -0,0 +1,53 @@
import type {
IEmailDnsRecord,
TDnsRecordStatus,
} from '../../ts_interfaces/data/email-domain.js';
type TEmailDnsStatusKey = 'mx' | 'spf' | 'dkim' | 'dmarc';
export interface IBuildEmailDnsRecordsOptions {
domain: string;
hostname: string;
selector?: string;
dkimValue?: string;
mxPriority?: number;
dmarcPolicy?: string;
dmarcRua?: string;
statuses?: Partial<Record<TEmailDnsStatusKey, TDnsRecordStatus>>;
}
export function buildEmailDnsRecords(options: IBuildEmailDnsRecordsOptions): IEmailDnsRecord[] {
const statusFor = (key: TEmailDnsStatusKey): TDnsRecordStatus => options.statuses?.[key] ?? 'unchecked';
const selector = options.selector || 'default';
const records: IEmailDnsRecord[] = [
{
type: 'MX',
name: options.domain,
value: `${options.mxPriority ?? 10} ${options.hostname}`,
status: statusFor('mx'),
},
{
type: 'TXT',
name: options.domain,
value: 'v=spf1 a mx ~all',
status: statusFor('spf'),
},
{
type: 'TXT',
name: `_dmarc.${options.domain}`,
value: `v=DMARC1; p=${options.dmarcPolicy ?? 'none'}; rua=mailto:${options.dmarcRua ?? `dmarc@${options.domain}`}`,
status: statusFor('dmarc'),
},
];
if (options.dkimValue) {
records.splice(2, 0, {
type: 'TXT',
name: `${selector}._domainkey.${options.domain}`,
value: options.dkimValue,
status: statusFor('dkim'),
});
}
return records;
}

View File

@@ -1,2 +1,3 @@
export * from './classes.email-domain.manager.js'; export * from './classes.email-domain.manager.js';
export * from './classes.smartmta-storage-manager.js'; export * from './classes.smartmta-storage-manager.js';
export * from './email-dns-records.js';

View File

@@ -87,12 +87,12 @@ export class RouteManagementHandler {
if (!manager) { if (!manager) {
return { success: false, message: 'Route management not initialized' }; return { success: false, message: 'Route management not initialized' };
} }
const ok = await manager.updateRoute(dataArg.id, { const result = await manager.updateRoute(dataArg.id, {
route: dataArg.route as any, route: dataArg.route as any,
enabled: dataArg.enabled, enabled: dataArg.enabled,
metadata: dataArg.metadata, metadata: dataArg.metadata,
}); });
return { success: ok, message: ok ? undefined : 'Route not found' }; return result;
}, },
), ),
); );
@@ -107,8 +107,7 @@ export class RouteManagementHandler {
if (!manager) { if (!manager) {
return { success: false, message: 'Route management not initialized' }; return { success: false, message: 'Route management not initialized' };
} }
const ok = await manager.deleteRoute(dataArg.id); return manager.deleteRoute(dataArg.id);
return { success: ok, message: ok ? undefined : 'Route not found' };
}, },
), ),
); );
@@ -123,8 +122,7 @@ export class RouteManagementHandler {
if (!manager) { if (!manager) {
return { success: false, message: 'Route management not initialized' }; return { success: false, message: 'Route management not initialized' };
} }
const ok = await manager.toggleRoute(dataArg.id, dataArg.enabled); return manager.toggleRoute(dataArg.id, dataArg.enabled);
return { success: ok, message: ok ? undefined : 'Route not found' };
}, },
), ),
); );

View File

@@ -1,8 +1,8 @@
# @serve.zone/dcrouter-apiclient # @serve.zone/dcrouter-apiclient
A typed, object-oriented API client for DcRouter with a fluent builder pattern. 🔧 Typed, object-oriented API client for operating a running dcrouter instance. 🔧
Programmatically manage your DcRouter instance — routes, certificates, API tokens, remote ingress edges, RADIUS, email operations, and more — all with full TypeScript type safety and an intuitive OO interface. Use this package when you want a clean TypeScript client instead of manually firing TypedRequest calls. It wraps the OpsServer API in resource managers and resource classes such as routes, certificates, tokens, edges, emails, stats, logs, config, and RADIUS.
## Issue Reporting and Security ## Issue Reporting and Security
@@ -14,7 +14,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
pnpm add @serve.zone/dcrouter-apiclient pnpm add @serve.zone/dcrouter-apiclient
``` ```
Or import directly from the main package: Or import through the main package:
```typescript ```typescript
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient'; import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
@@ -23,239 +23,113 @@ import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
## Quick Start ## Quick Start
```typescript ```typescript
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient'; import { DcRouterApiClient } from '@serve.zone/dcrouter-apiclient';
const client = new DcRouterApiClient({ baseUrl: 'https://dcrouter.example.com' }); const client = new DcRouterApiClient({
baseUrl: 'https://dcrouter.example.com',
});
// Authenticate
await client.login('admin', 'password'); await client.login('admin', 'password');
// List routes const { routes } = await client.routes.list();
const { routes, warnings } = await client.routes.list(); console.log(routes.map((route) => `${route.origin}:${route.name}`));
console.log(`${routes.length} routes, ${warnings.length} warnings`);
// Check health await client.routes.build()
const { health } = await client.stats.getHealth(); .setName('api-gateway')
console.log(`Healthy: ${health.healthy}`); .setMatch({ ports: 443, domains: ['api.example.com'] })
.setAction({ type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] })
.save();
``` ```
## Usage ## Authentication Modes
### 🔐 Authentication | Mode | How it works |
| --- | --- |
| Admin login | Call `login(username, password)` and the client stores the returned identity for later requests |
| API token | Pass `apiToken` into the constructor for token-based automation |
```typescript ```typescript
// Login with credentials — identity is stored and auto-injected into all subsequent requests
const identity = await client.login('admin', 'password');
// Verify current session
const { valid } = await client.verifyIdentity();
// Logout
await client.logout();
// Or use an API token for programmatic access (route management only)
const client = new DcRouterApiClient({ const client = new DcRouterApiClient({
baseUrl: 'https://dcrouter.example.com', baseUrl: 'https://dcrouter.example.com',
apiToken: 'dcr_your_token_here', apiToken: 'dcr_your_token_here',
}); });
``` ```
### 🌐 Routes — OO Resources + Builder ## Main Managers
Routes are returned as `Route` instances with methods for update, delete, toggle, and overrides: | Manager | Purpose |
| --- | --- |
| `client.routes` | List routes and create API-managed routes |
| `client.certificates` | Inspect and operate on certificate records |
| `client.apiTokens` | Create, list, toggle, roll, revoke API tokens |
| `client.remoteIngress` | Manage registered remote ingress edges |
| `client.stats` | Read operational metrics and health data |
| `client.config` | Read current configuration view |
| `client.logs` | Read recent logs or stream them |
| `client.emails` | List emails and trigger resend flows |
| `client.radius` | Operate on RADIUS clients, VLANs, sessions, and accounting |
## Route Behavior
Routes are returned as `Route` instances with:
- `id`
- `name`
- `enabled`
- `origin`
Important behavior:
- API routes can be created, updated, deleted, and toggled.
- System routes can be listed and toggled, but not edited or deleted.
- A system route is any route whose `origin !== 'api'`.
```typescript ```typescript
// List all routes (hardcoded + programmatic) const { routes } = await client.routes.list();
const { routes, warnings } = await client.routes.list();
// Inspect a route for (const route of routes) {
const route = routes[0]; if (route.origin !== 'api') {
console.log(route.name, route.source, route.enabled); await route.toggle(false);
}
// Modify a programmatic route }
await route.update({ name: 'renamed-route' });
await route.toggle(false);
await route.delete();
// Override a hardcoded route (disable it)
const hardcodedRoute = routes.find(r => r.source === 'hardcoded');
await hardcodedRoute.setOverride(false);
await hardcodedRoute.removeOverride();
``` ```
**Builder pattern** for creating new routes: ## Builder Example
```typescript ```typescript
const newRoute = await client.routes.build() const route = await client.routes.build()
.setName('api-gateway') .setName('internal-app')
.setMatch({ ports: 443, domains: ['api.example.com'] }) .setMatch({
.setAction({ type: 'forward', targets: [{ host: 'backend', port: 8080 }] }) ports: 80,
.setTls({ mode: 'terminate', certificate: 'auto' }) domains: ['internal.example.com'],
})
.setAction({
type: 'forward',
targets: [{ host: '127.0.0.1', port: 3000 }],
})
.setEnabled(true) .setEnabled(true)
.save(); .save();
// Or use quick creation await route.toggle(false);
const route = await client.routes.create(routeConfig);
``` ```
### 🔑 API Tokens ## Example: Certificates and Stats
```typescript
// List existing tokens
const tokens = await client.apiTokens.list();
// Create with builder
const token = await client.apiTokens.build()
.setName('ci-pipeline')
.setScopes(['routes:read', 'routes:write'])
.addScope('config:read')
.setExpiresInDays(90)
.save();
console.log(token.tokenValue); // Only available at creation time!
// Manage tokens
await token.toggle(false); // Disable
const newValue = await token.roll(); // Regenerate secret
await token.revoke(); // Delete
```
### 🔐 Certificates
```typescript ```typescript
const { certificates, summary } = await client.certificates.list(); const { certificates, summary } = await client.certificates.list();
console.log(`${summary.valid} valid, ${summary.expiring} expiring, ${summary.failed} failed`); console.log(summary.valid, summary.failed);
// Operate on individual certificates const health = await client.stats.getHealth();
const cert = certificates[0]; const recentLogs = await client.logs.getRecent({ level: 'error', limit: 20 });
await cert.reprovision();
const exported = await cert.export();
await cert.delete();
// Import a certificate
await client.certificates.import({
id: 'cert-id',
domainName: 'example.com',
created: Date.now(),
validUntil: Date.now() + 90 * 24 * 3600 * 1000,
privateKey: '...',
publicKey: '...',
csr: '...',
});
``` ```
### 🌍 Remote Ingress ## What This Package Does Not Do
```typescript - It does not start dcrouter.
// List edges and their statuses - It does not embed the dashboard.
const edges = await client.remoteIngress.list(); - It does not replace the request interfaces package if you only need raw types.
const statuses = await client.remoteIngress.getStatuses();
// Create with builder Use `@serve.zone/dcrouter` to run the server, `@serve.zone/dcrouter-web` for the dashboard bundle/components, and `@serve.zone/dcrouter-interfaces` for raw API contracts.
const edge = await client.remoteIngress.build()
.setName('edge-nyc-01')
.setListenPorts([80, 443])
.setAutoDerivePorts(true)
.setTags(['us-east'])
.save();
// Manage an edge
await edge.update({ name: 'edge-nyc-02' });
const newSecret = await edge.regenerateSecret();
const token = await edge.getConnectionToken();
await edge.delete();
```
### 📊 Statistics (Read-Only)
```typescript
const serverStats = await client.stats.getServer({ timeRange: '24h', includeHistory: true });
const emailStats = await client.stats.getEmail({ domain: 'example.com' });
const dnsStats = await client.stats.getDns();
const security = await client.stats.getSecurity({ includeDetails: true });
const connections = await client.stats.getConnections({ protocol: 'https' });
const queues = await client.stats.getQueues();
const health = await client.stats.getHealth(true);
const network = await client.stats.getNetwork();
const combined = await client.stats.getCombined({ server: true, email: true });
```
### ⚙️ Configuration & Logs
```typescript
// Read-only configuration
const config = await client.config.get();
const emailSection = await client.config.get('email');
// Logs
const { logs, total, hasMore } = await client.logs.getRecent({
level: 'error',
category: 'smtp',
limit: 50,
});
```
### 📧 Email Operations
```typescript
const emails = await client.emails.list();
const email = emails[0];
const detail = await email.getDetail();
await email.resend();
// Or use the manager directly
const detail2 = await client.emails.getDetail('email-id');
await client.emails.resend('email-id');
```
### 📡 RADIUS
```typescript
// Client management
const clients = await client.radius.clients.list();
await client.radius.clients.set({
name: 'switch-1',
ipRange: '192.168.1.0/24',
secret: 'shared-secret',
enabled: true,
});
await client.radius.clients.remove('switch-1');
// VLAN management
const { mappings, config: vlanConfig } = await client.radius.vlans.list();
await client.radius.vlans.set({ mac: 'aa:bb:cc:dd:ee:ff', vlan: 10, enabled: true });
const result = await client.radius.vlans.testAssignment('aa:bb:cc:dd:ee:ff');
await client.radius.vlans.updateConfig({ defaultVlan: 200 });
// Sessions
const { sessions } = await client.radius.sessions.list({ vlanId: 10 });
await client.radius.sessions.disconnect('session-id', 'Admin disconnect');
// Statistics & Accounting
const stats = await client.radius.getStatistics();
const summary = await client.radius.getAccountingSummary(startTime, endTime);
```
## API Surface
| Manager | Methods |
|---------|---------|
| `client.login()` / `logout()` / `verifyIdentity()` | Authentication |
| `client.routes` | `list()`, `create()`, `build()` → Route: `update()`, `delete()`, `toggle()`, `setOverride()`, `removeOverride()` |
| `client.certificates` | `list()`, `import()` → Certificate: `reprovision()`, `delete()`, `export()` |
| `client.apiTokens` | `list()`, `create()`, `build()` → ApiToken: `revoke()`, `roll()`, `toggle()` |
| `client.remoteIngress` | `list()`, `getStatuses()`, `create()`, `build()` → RemoteIngress: `update()`, `delete()`, `regenerateSecret()`, `getConnectionToken()` |
| `client.stats` | `getServer()`, `getEmail()`, `getDns()`, `getRateLimits()`, `getSecurity()`, `getConnections()`, `getQueues()`, `getHealth()`, `getNetwork()`, `getCombined()` |
| `client.config` | `get(section?)` |
| `client.logs` | `getRecent()`, `getStream()` |
| `client.emails` | `list()`, `getDetail()`, `resend()` → Email: `getDetail()`, `resend()` |
| `client.radius` | `.clients.list/set/remove()`, `.vlans.list/set/remove/updateConfig/testAssignment()`, `.sessions.list/disconnect()`, `getStatistics()`, `getAccountingSummary()` |
## Architecture
The client uses HTTP-based [TypedRequest](https://code.foss.global/api.global/typedrequest) for transport. All requests are sent as POST to `{baseUrl}/typedrequest`. Authentication (JWT identity and/or API token) is automatically injected into every request payload via `buildRequestPayload()`.
Resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) hold a reference to the client and provide instance methods that fire the appropriate TypedRequest operations. Builder classes (`RouteBuilder`, `ApiTokenBuilder`, `RemoteIngressBuilder`) use fluent chaining and a terminal `.save()` method.
## License and Legal Information ## License and Legal Information

View File

@@ -90,6 +90,7 @@ export interface IMergedRoute {
id: string; id: string;
enabled: boolean; enabled: boolean;
origin: 'config' | 'email' | 'dns' | 'api'; origin: 'config' | 'email' | 'dns' | 'api';
systemKey?: string;
createdAt?: number; createdAt?: number;
updatedAt?: number; updatedAt?: number;
metadata?: IRouteMetadata; metadata?: IRouteMetadata;
@@ -132,6 +133,7 @@ export interface IRoute {
updatedAt: number; updatedAt: number;
createdBy: string; createdBy: string;
origin: 'config' | 'email' | 'dns' | 'api'; origin: 'config' | 'email' | 'dns' | 'api';
systemKey?: string;
metadata?: IRouteMetadata; metadata?: IRouteMetadata;
} }

View File

@@ -1,8 +1,8 @@
# @serve.zone/dcrouter-interfaces # @serve.zone/dcrouter-interfaces
TypeScript interfaces and type definitions for the DcRouter OpsServer API. 📡 Shared TypeScript request and data interfaces for dcrouter's OpsServer API. 📡
This module provides strongly-typed interfaces for communicating with the DcRouter OpsServer via [TypedRequest](https://code.foss.global/api.global/typedrequest). Use these interfaces for type-safe API interactions in your frontend applications or integration code. This package is the contract layer for typed clients, frontend code, tests, or automation that talks to a running dcrouter instance through TypedRequest.
## Issue Reporting and Security ## Issue Reporting and Security
@@ -14,320 +14,79 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
pnpm add @serve.zone/dcrouter-interfaces pnpm add @serve.zone/dcrouter-interfaces
``` ```
Or import directly from the main package: Or consume the same interfaces through the main package:
```typescript ```typescript
import { data, requests } from '@serve.zone/dcrouter/interfaces'; import { data, requests } from '@serve.zone/dcrouter/interfaces';
``` ```
## Usage ## What It Exports
The package exposes two namespaces from `index.ts`:
| Export | Purpose |
| --- | --- |
| `data` | Shared runtime-shaped types such as route data, auth identity, stats, domains, certificates, VPN, DNS, and email-domain data |
| `requests` | TypedRequest request and response contracts for every OpsServer endpoint |
## Example
```typescript ```typescript
import * as typedrequest from '@api.global/typedrequest';
import { data, requests } from '@serve.zone/dcrouter-interfaces'; import { data, requests } from '@serve.zone/dcrouter-interfaces';
// Use data interfaces for type definitions
const identity: data.IIdentity = { const identity: data.IIdentity = {
jwt: 'your-jwt-token', jwt: 'jwt-token',
userId: 'user-123', userId: 'admin-1',
name: 'Admin User', name: 'Admin',
expiresAt: Date.now() + 3600000, expiresAt: Date.now() + 60_000,
role: 'admin' role: 'admin',
}; };
// Use request interfaces for API calls const request = new typedrequest.TypedRequest<requests.IReq_GetMergedRoutes>(
import * as typedrequest from '@api.global/typedrequest'; 'https://dcrouter.example.com/typedrequest',
'getMergedRoutes',
const statsClient = new typedrequest.TypedRequest<requests.IReq_GetServerStatistics>(
'https://your-dcrouter:3000/typedrequest',
'getServerStatistics'
); );
const stats = await statsClient.fire({ const response = await request.fire({ identity });
identity,
includeHistory: true,
timeRange: '24h'
});
```
## Module Structure for (const route of response.routes) {
console.log(route.id, route.origin, route.systemKey, route.enabled);
### Data Interfaces (`data`)
Core data types used throughout the DcRouter system:
#### `IIdentity`
Authentication identity for API requests:
```typescript
interface IIdentity {
jwt: string; // JWT token
userId: string; // Unique user ID
name: string; // Display name
expiresAt: number; // Token expiration timestamp
role?: string; // User role (e.g., 'admin')
type?: string; // Identity type
} }
``` ```
#### Statistics Interfaces ## API Domains Covered
| Interface | Description |
|-----------|-------------|
| `IServerStats` | Uptime, memory, CPU, connection counts |
| `IEmailStats` | Sent/received/bounced/queued/failed, delivery & bounce rates |
| `IDnsStats` | Total queries, cache hits/misses, query types |
| `IRateLimitInfo` | Domain rate limit status (current rate, limit, remaining) |
| `ISecurityMetrics` | Blocked IPs, spam/malware/phishing counts |
| `IConnectionInfo` | Connection ID, remote address, protocol, state, bytes |
| `IQueueStatus` | Queue name, size, processing/failed/retrying counts |
| `IHealthStatus` | Healthy flag, uptime, per-service status map |
| `INetworkMetrics` | Bandwidth, connection counts, top endpoints |
| `IRadiusStats` | Running, uptime, auth requests/accepts/rejects, sessions, data transfer |
| `IVpnStats` | Running, subnet, registered/connected clients, WireGuard port |
| `ILogEntry` | Timestamp, level, category, message, metadata |
#### Route Management Interfaces | Domain | Examples |
| Interface | Description | | --- | --- |
|-----------|-------------| | Auth | admin login, logout, identity verification |
| `IMergedRoute` | Combined route: routeConfig, source (hardcoded/programmatic), enabled, overridden | | Routes | merged routes, create, update, delete, toggle |
| `IRouteWarning` | Merge warning: disabled-hardcoded, disabled-programmatic, orphaned-override | | Access | API tokens, source profiles, target profiles, network targets |
| `IApiTokenInfo` | Token info: id, name, scopes, createdAt, expiresAt, enabled | | DNS and domains | providers, domains, DNS records |
| `TApiTokenScope` | Token scopes: `routes:read`, `routes:write`, `config:read`, `tokens:read`, `tokens:manage` | | Certificates | overview, reprovision, import, export, delete, ACME config |
| Email | email operations, email domains |
| Remote ingress | edge registrations, status, connection tokens |
| VPN | clients, status, telemetry, lifecycle |
| RADIUS | clients, VLANs, sessions, accounting |
| Observability | stats, logs, health, configuration |
#### Security & Reference Interfaces ## Notable Data Types
| Interface | Description |
|-----------|-------------|
| `ISecurityProfile` | Reusable security config: id, name, description, security (ipAllowList, ipBlockList, maxConnections, rateLimit, etc.), extendsProfiles |
| `INetworkTarget` | Reusable host:port destination: id, name, description, host (string or string[]), port |
| `IRouteMetadata` | Route-to-reference links: securityProfileRef, networkTargetRef, snapshot names, lastResolvedAt |
#### Remote Ingress Interfaces | Type | Description |
| Interface | Description | | --- | --- |
|-----------|-------------| | `data.IMergedRoute` | Route entry returned by route management, including `origin`, `enabled`, and optional `systemKey` |
| `IRemoteIngress` | Edge registration: id, name, secret, listenPorts, enabled, autoDerivePorts, tags | | `data.IDcRouterRouteConfig` | dcrouter-flavored route config used across the stack |
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat | | `data.IRouteMetadata` | Reference metadata connecting routes to source profiles or network targets |
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter | | `data.IIdentity` | Admin identity used for authenticated requests |
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` and `vpn` properties | | `data.IApiTokenInfo` | Public token metadata without the secret |
| `IRouteVpn` | Route-level VPN config: `enabled`/`mandatory` flags and optional `allowedServerDefinedClientTags` |
#### VPN Interfaces ## When To Use This Package
| Interface | Description |
|-----------|-------------|
| `IVpnClient` | Client registration: clientId, enabled, serverDefinedClientTags, description, assignedIp, timestamps |
| `IVpnServerStatus` | Server status: running, subnet, wgListenPort, publicKeys, client counts |
| `IVpnClientTelemetry` | Per-client metrics: bytes sent/received, packets dropped, keepalives, rate limits |
### Request Interfaces (`requests`) - Use it in custom dashboards or CLIs that call TypedRequest directly.
- Use it in tests that need strongly typed request payloads or response assertions.
- Use it when you want the API contract without pulling in the OO client.
TypedRequest interfaces for the OpsServer API, organized by domain: If you want a higher-level client with managers and resource classes, use `@serve.zone/dcrouter-apiclient` instead.
#### 🔐 Authentication
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_AdminLoginWithUsernameAndPassword` | `adminLoginWithUsernameAndPassword` | Authenticate as admin |
| `IReq_AdminLogout` | `adminLogout` | End admin session |
| `IReq_VerifyIdentity` | `verifyIdentity` | Verify JWT token validity |
#### 📊 Statistics
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetServerStatistics` | `getServerStatistics` | Overall server stats |
| `IReq_GetEmailStatistics` | `getEmailStatistics` | Email throughput metrics |
| `IReq_GetDnsStatistics` | `getDnsStatistics` | DNS query stats |
| `IReq_GetRateLimitStatus` | `getRateLimitStatus` | Rate limit status |
| `IReq_GetSecurityMetrics` | `getSecurityMetrics` | Security event metrics |
| `IReq_GetActiveConnections` | `getActiveConnections` | Active connection list |
| `IReq_GetQueueStatus` | `getQueueStatus` | Email queue status |
| `IReq_GetHealthStatus` | `getHealthStatus` | System health check |
| `IReq_GetNetworkStats` | `getNetworkStats` | Network throughput and connection analytics |
| `IReq_GetCombinedMetrics` | `getCombinedMetrics` | All metrics in one request (server, email, DNS, security, network, RADIUS, VPN) |
#### ⚙️ Configuration
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetConfiguration` | `getConfiguration` | Current config (read-only) |
#### 📜 Logs
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetRecentLogs` | `getLogs` | Retrieve system logs |
| `IReq_GetLogStream` | `getLogStream` | Stream live logs |
#### 📧 Email Operations
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetAllEmails` | `getAllEmails` | List all emails |
| `IReq_GetEmailDetail` | `getEmailDetail` | Full detail for a specific email |
| `IReq_ResendEmail` | `resendEmail` | Re-queue a failed email |
#### 🛣️ Route Management
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetMergedRoutes` | `getMergedRoutes` | List all routes (hardcoded + programmatic) |
| `IReq_CreateRoute` | `createRoute` | Create a new programmatic route |
| `IReq_UpdateRoute` | `updateRoute` | Update a programmatic route |
| `IReq_DeleteRoute` | `deleteRoute` | Delete a programmatic route |
| `IReq_ToggleRoute` | `toggleRoute` | Enable/disable a programmatic route |
| `IReq_SetRouteOverride` | `setRouteOverride` | Override a hardcoded route |
| `IReq_RemoveRouteOverride` | `removeRouteOverride` | Remove a route override |
#### 🔑 API Token Management
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_CreateApiToken` | `createApiToken` | Create a new API token |
| `IReq_ListApiTokens` | `listApiTokens` | List all tokens |
| `IReq_RevokeApiToken` | `revokeApiToken` | Revoke (delete) a token |
| `IReq_RollApiToken` | `rollApiToken` | Regenerate token secret |
| `IReq_ToggleApiToken` | `toggleApiToken` | Enable/disable a token |
#### 🔐 Certificates
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetCertificateOverview` | `getCertificateOverview` | Domain-centric certificate status |
| `IReq_ReprovisionCertificate` | `reprovisionCertificate` | Reprovision by route name (legacy) |
| `IReq_ReprovisionCertificateDomain` | `reprovisionCertificateDomain` | Reprovision by domain (preferred) |
| `IReq_ImportCertificate` | `importCertificate` | Import a certificate |
| `IReq_ExportCertificate` | `exportCertificate` | Export a certificate |
| `IReq_DeleteCertificate` | `deleteCertificate` | Delete a certificate |
#### Certificate Types
```typescript
type TCertificateStatus = 'valid' | 'expiring' | 'expired' | 'provisioning' | 'failed' | 'unknown';
type TCertificateSource = 'acme' | 'provision-function' | 'static' | 'none';
interface ICertificateInfo {
domain: string;
routeNames: string[];
status: TCertificateStatus;
source: TCertificateSource;
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
expiryDate?: string;
issuer?: string;
issuedAt?: string;
error?: string;
canReprovision: boolean;
backoffInfo?: {
failures: number;
retryAfter?: string;
lastError?: string;
};
}
```
#### 🌍 Remote Ingress
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_CreateRemoteIngress` | `createRemoteIngress` | Register a new edge node |
| `IReq_DeleteRemoteIngress` | `deleteRemoteIngress` | Remove an edge registration |
| `IReq_UpdateRemoteIngress` | `updateRemoteIngress` | Update edge settings |
| `IReq_RegenerateRemoteIngressSecret` | `regenerateRemoteIngressSecret` | Issue a new secret |
| `IReq_GetRemoteIngresses` | `getRemoteIngresses` | List all edge registrations |
| `IReq_GetRemoteIngressStatus` | `getRemoteIngressStatus` | Runtime status of all edges |
| `IReq_GetRemoteIngressConnectionToken` | `getRemoteIngressConnectionToken` | Generate a connection token |
#### 🔐 VPN
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetVpnClients` | `getVpnClients` | List all registered VPN clients |
| `IReq_GetVpnStatus` | `getVpnStatus` | VPN server status |
| `IReq_CreateVpnClient` | `createVpnClient` | Create a new VPN client (returns WireGuard config) |
| `IReq_DeleteVpnClient` | `deleteVpnClient` | Remove a VPN client |
| `IReq_EnableVpnClient` | `enableVpnClient` | Enable a disabled client |
| `IReq_DisableVpnClient` | `disableVpnClient` | Disable a client |
| `IReq_RotateVpnClientKey` | `rotateVpnClientKey` | Generate new keys for a client |
| `IReq_ExportVpnClientConfig` | `exportVpnClientConfig` | Export WireGuard or SmartVPN config |
| `IReq_GetVpnClientTelemetry` | `getVpnClientTelemetry` | Per-client traffic metrics |
#### 📡 RADIUS
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetRadiusClients` | `getRadiusClients` | List NAS clients |
| `IReq_SetRadiusClient` | `setRadiusClient` | Add/update a NAS client |
| `IReq_RemoveRadiusClient` | `removeRadiusClient` | Remove a NAS client |
| `IReq_GetVlanMappings` | `getVlanMappings` | List VLAN mappings |
| `IReq_SetVlanMapping` | `setVlanMapping` | Add/update VLAN mapping |
| `IReq_RemoveVlanMapping` | `removeVlanMapping` | Remove VLAN mapping |
| `IReq_TestVlanAssignment` | `testVlanAssignment` | Test what VLAN a MAC gets |
| `IReq_GetRadiusSessions` | `getRadiusSessions` | List active sessions |
| `IReq_DisconnectRadiusSession` | `disconnectRadiusSession` | Force disconnect |
| `IReq_GetRadiusStatistics` | `getRadiusStatistics` | RADIUS stats |
| `IReq_GetRadiusAccountingSummary` | `getRadiusAccountingSummary` | Accounting summary |
#### 🛡️ Security Profiles
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetSecurityProfiles` | `getSecurityProfiles` | List all security profiles |
| `IReq_GetSecurityProfile` | `getSecurityProfile` | Get a single profile by ID |
| `IReq_CreateSecurityProfile` | `createSecurityProfile` | Create a reusable security profile |
| `IReq_UpdateSecurityProfile` | `updateSecurityProfile` | Update a profile (propagates to routes) |
| `IReq_DeleteSecurityProfile` | `deleteSecurityProfile` | Delete a profile (with optional force) |
| `IReq_GetSecurityProfileUsage` | `getSecurityProfileUsage` | Get routes referencing a profile |
#### 🎯 Network Targets
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetNetworkTargets` | `getNetworkTargets` | List all network targets |
| `IReq_GetNetworkTarget` | `getNetworkTarget` | Get a single target by ID |
| `IReq_CreateNetworkTarget` | `createNetworkTarget` | Create a reusable host:port target |
| `IReq_UpdateNetworkTarget` | `updateNetworkTarget` | Update a target (propagates to routes) |
| `IReq_DeleteNetworkTarget` | `deleteNetworkTarget` | Delete a target (with optional force) |
| `IReq_GetNetworkTargetUsage` | `getNetworkTargetUsage` | Get routes referencing a target |
## Example: Full API Integration
> 💡 **Tip:** For a higher-level, object-oriented API, use [`@serve.zone/dcrouter-apiclient`](https://www.npmjs.com/package/@serve.zone/dcrouter-apiclient) which wraps these interfaces with resource classes and builder patterns.
```typescript
import * as typedrequest from '@api.global/typedrequest';
import { data, requests } from '@serve.zone/dcrouter-interfaces';
// 1. Login
const loginClient = new typedrequest.TypedRequest<requests.IReq_AdminLoginWithUsernameAndPassword>(
'https://your-dcrouter:3000/typedrequest',
'adminLoginWithUsernameAndPassword'
);
const loginResponse = await loginClient.fire({
username: 'admin',
password: 'your-password'
});
const identity = loginResponse.identity;
// 2. Fetch combined metrics
const metricsClient = new typedrequest.TypedRequest<requests.IReq_GetCombinedMetrics>(
'https://your-dcrouter:3000/typedrequest',
'getCombinedMetrics'
);
const metrics = await metricsClient.fire({ identity });
console.log('Server:', metrics.metrics.server);
console.log('Email:', metrics.metrics.email);
// 3. Check certificate status
const certClient = new typedrequest.TypedRequest<requests.IReq_GetCertificateOverview>(
'https://your-dcrouter:3000/typedrequest',
'getCertificateOverview'
);
const certs = await certClient.fire({ identity });
console.log(`Certificates: ${certs.summary.valid} valid, ${certs.summary.failed} failed`);
// 4. List remote ingress edges
const edgesClient = new typedrequest.TypedRequest<requests.IReq_GetRemoteIngresses>(
'https://your-dcrouter:3000/typedrequest',
'getRemoteIngresses'
);
const edges = await edgesClient.fire({ identity });
console.log('Registered edges:', edges.edges.length);
// 5. Generate a connection token for an edge
const tokenClient = new typedrequest.TypedRequest<requests.IReq_GetRemoteIngressConnectionToken>(
'https://your-dcrouter:3000/typedrequest',
'getRemoteIngressConnectionToken'
);
const tokenResponse = await tokenClient.fire({ identity, edgeId: edges.edges[0].id });
console.log('Connection token:', tokenResponse.token);
```
## License and Legal Information ## License and Legal Information

View File

@@ -45,6 +45,33 @@ async function migrateTargetProfileTargetHosts(ctx: {
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`); ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
} }
async function backfillSystemRouteKeys(ctx: {
mongo?: { collection: (name: string) => any };
log: { log: (level: 'info', message: string) => void };
}): Promise<void> {
const collection = ctx.mongo!.collection('RouteDoc');
const cursor = collection.find({
origin: { $in: ['config', 'email', 'dns'] },
systemKey: { $exists: false },
'route.name': { $type: 'string' },
});
let migrated = 0;
for await (const doc of cursor) {
const origin = typeof (doc as any).origin === 'string' ? (doc as any).origin : undefined;
const routeName = typeof (doc as any).route?.name === 'string' ? (doc as any).route.name.trim() : '';
if (!origin || !routeName) continue;
await collection.updateOne(
{ _id: (doc as any)._id },
{ $set: { systemKey: `${origin}:${routeName}` } },
);
migrated++;
}
ctx.log.log('info', `backfill-system-route-keys: migrated ${migrated} route(s)`);
}
/** /**
* Create a configured SmartMigration runner with all dcrouter migration steps registered. * Create a configured SmartMigration runner with all dcrouter migration steps registered.
* *
@@ -134,6 +161,12 @@ export async function createMigrationRunner(
.description('Repair TargetProfileDoc.targets host→ip migration for already-upgraded installs') .description('Repair TargetProfileDoc.targets host→ip migration for already-upgraded installs')
.up(async (ctx) => { .up(async (ctx) => {
await migrateTargetProfileTargetHosts(ctx); await migrateTargetProfileTargetHosts(ctx);
})
.step('backfill-system-route-keys')
.from('13.17.4').to('13.18.0')
.description('Backfill RouteDoc.systemKey for persisted config/email/dns routes')
.up(async (ctx) => {
await backfillSystemRouteKeys(ctx);
}); });
return migration; return migration;

67
ts_migrations/readme.md Normal file
View File

@@ -0,0 +1,67 @@
# @serve.zone/dcrouter-migrations
Migration runner package for dcrouter's smartdata-backed persistence layer. 🧱
This package provides the startup migration chain that upgrades dcrouter data across releases before the application reads from the database.
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## What It Exports
| Export | Purpose |
| --- | --- |
| `createMigrationRunner(db, targetVersion)` | Builds the dcrouter SmartMigration runner for the current application version |
| `IMigrationRunner` | Small interface describing the runner's `run()` method |
| `IMigrationRunResult` | Logged result shape used after execution |
## Usage
```typescript
import { createMigrationRunner } from '@serve.zone/dcrouter-migrations';
const migration = await createMigrationRunner(db, '13.18.0');
const result = await migration.run();
console.log(result.currentVersionBefore, result.currentVersionAfter);
```
## What These Migrations Handle
The migration chain currently covers dcrouter-specific storage transitions such as:
- target profile target field renames
- domain and DNS record source renames
- route collection unification into `RouteDoc`
- persisted route metadata backfills such as `origin` and `systemKey`
## Important Behavior
- fresh installs are stamped directly to the current target version
- migration steps are registered in strict version order
- migrations run before services load DB-backed state
- route-related migrations use smartdata collection names exactly as declared in code
If you are embedding dcrouter's DB layer outside the main runtime, run this package before any feature code assumes the latest schema.
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

View File

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

View File

@@ -2150,7 +2150,7 @@ export const updateRouteAction = routeManagementStatePart.createAction<{
interfaces.requests.IReq_UpdateRoute interfaces.requests.IReq_UpdateRoute
>('/typedrequest', 'updateRoute'); >('/typedrequest', 'updateRoute');
await request.fire({ const response = await request.fire({
identity: context.identity!, identity: context.identity!,
id: dataArg.id, id: dataArg.id,
route: dataArg.route, route: dataArg.route,
@@ -2158,6 +2158,10 @@ export const updateRouteAction = routeManagementStatePart.createAction<{
metadata: dataArg.metadata, metadata: dataArg.metadata,
}); });
if (!response.success) {
throw new Error(response.message || 'Failed to update route');
}
return await actionContext!.dispatch(fetchMergedRoutesAction, null); return await actionContext!.dispatch(fetchMergedRoutesAction, null);
} catch (error: unknown) { } catch (error: unknown) {
return { return {
@@ -2177,11 +2181,15 @@ export const deleteRouteAction = routeManagementStatePart.createAction<string>(
interfaces.requests.IReq_DeleteRoute interfaces.requests.IReq_DeleteRoute
>('/typedrequest', 'deleteRoute'); >('/typedrequest', 'deleteRoute');
await request.fire({ const response = await request.fire({
identity: context.identity!, identity: context.identity!,
id: routeId, id: routeId,
}); });
if (!response.success) {
throw new Error(response.message || 'Failed to delete route');
}
return await actionContext!.dispatch(fetchMergedRoutesAction, null); return await actionContext!.dispatch(fetchMergedRoutesAction, null);
} catch (error: unknown) { } catch (error: unknown) {
return { return {
@@ -2204,12 +2212,16 @@ export const toggleRouteAction = routeManagementStatePart.createAction<{
interfaces.requests.IReq_ToggleRoute interfaces.requests.IReq_ToggleRoute
>('/typedrequest', 'toggleRoute'); >('/typedrequest', 'toggleRoute');
await request.fire({ const response = await request.fire({
identity: context.identity!, identity: context.identity!,
id: dataArg.id, id: dataArg.id,
enabled: dataArg.enabled, enabled: dataArg.enabled,
}); });
if (!response.success) {
throw new Error(response.message || 'Failed to toggle route');
}
return await actionContext!.dispatch(fetchMergedRoutesAction, null); return await actionContext!.dispatch(fetchMergedRoutesAction, null);
} catch (error: unknown) { } catch (error: unknown) {
return { return {
@@ -2765,4 +2777,4 @@ startAutoRefresh();
// Connect TypedSocket if already logged in (e.g., persistent session) // Connect TypedSocket if already logged in (e.g., persistent session)
if (loginStatePart.getState()!.isLoggedIn) { if (loginStatePart.getState()!.isLoggedIn) {
connectSocket(); connectSocket();
} }

View File

@@ -272,15 +272,13 @@ export class OpsViewRoutes extends DeesElement {
const clickedRoute = e.detail; const clickedRoute = e.detail;
if (!clickedRoute) return; if (!clickedRoute) return;
// Find the corresponding merged route const merged = this.findMergedRoute(clickedRoute);
const merged = this.routeState.mergedRoutes.find(
(mr) => mr.route.name === clickedRoute.name,
);
if (!merged) return; if (!merged) return;
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
const meta = merged.metadata; const meta = merged.metadata;
const isSystemManaged = this.isSystemManagedRoute(merged);
await DeesModal.createAndShow({ await DeesModal.createAndShow({
heading: `Route: ${merged.route.name}`, heading: `Route: ${merged.route.name}`,
content: html` content: html`
@@ -288,6 +286,7 @@ export class OpsViewRoutes extends DeesElement {
<p>Origin: <strong style="color: #0af;">${merged.origin}</strong></p> <p>Origin: <strong style="color: #0af;">${merged.origin}</strong></p>
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p> <p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
<p>ID: <code style="color: #888;">${merged.id}</code></p> <p>ID: <code style="color: #888;">${merged.id}</code></p>
${isSystemManaged ? html`<p>This route is system-managed. Change its source config to modify it directly.</p>` : ''}
${meta?.sourceProfileName ? html`<p>Source Profile: <strong style="color: #a78bfa;">${meta.sourceProfileName}</strong></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>` : ''} ${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></p>` : ''}
</div> </div>
@@ -304,25 +303,29 @@ export class OpsViewRoutes extends DeesElement {
await modalArg.destroy(); await modalArg.destroy();
}, },
}, },
{ ...(!isSystemManaged
name: 'Edit', ? [
iconName: 'lucide:pencil', {
action: async (modalArg: any) => { name: 'Edit',
await modalArg.destroy(); iconName: 'lucide:pencil',
this.showEditRouteDialog(merged); action: async (modalArg: any) => {
}, await modalArg.destroy();
}, this.showEditRouteDialog(merged);
{ },
name: 'Delete', },
iconName: 'lucide:trash-2', {
action: async (modalArg: any) => { name: 'Delete',
await appstate.routeManagementStatePart.dispatchAction( iconName: 'lucide:trash-2',
appstate.deleteRouteAction, action: async (modalArg: any) => {
merged.id, await appstate.routeManagementStatePart.dispatchAction(
); appstate.deleteRouteAction,
await modalArg.destroy(); merged.id,
}, );
}, await modalArg.destroy();
},
},
]
: []),
{ {
name: 'Close', name: 'Close',
iconName: 'lucide:x', iconName: 'lucide:x',
@@ -336,10 +339,9 @@ export class OpsViewRoutes extends DeesElement {
const clickedRoute = e.detail; const clickedRoute = e.detail;
if (!clickedRoute) return; if (!clickedRoute) return;
const merged = this.routeState.mergedRoutes.find( const merged = this.findMergedRoute(clickedRoute);
(mr) => mr.route.name === clickedRoute.name,
);
if (!merged) return; if (!merged) return;
if (this.isSystemManagedRoute(merged)) return;
this.showEditRouteDialog(merged); this.showEditRouteDialog(merged);
} }
@@ -348,10 +350,9 @@ export class OpsViewRoutes extends DeesElement {
const clickedRoute = e.detail; const clickedRoute = e.detail;
if (!clickedRoute) return; if (!clickedRoute) return;
const merged = this.routeState.mergedRoutes.find( const merged = this.findMergedRoute(clickedRoute);
(mr) => mr.route.name === clickedRoute.name,
);
if (!merged) return; if (!merged) return;
if (this.isSystemManagedRoute(merged)) return;
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({ await DeesModal.createAndShow({
@@ -675,6 +676,23 @@ export class OpsViewRoutes extends DeesElement {
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null); appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
} }
private findMergedRoute(clickedRoute: { id?: string; name?: string }): interfaces.data.IMergedRoute | undefined {
if (clickedRoute.id) {
const routeById = this.routeState.mergedRoutes.find((mr) => mr.id === clickedRoute.id);
if (routeById) return routeById;
}
if (clickedRoute.name) {
return this.routeState.mergedRoutes.find((mr) => mr.route.name === clickedRoute.name);
}
return undefined;
}
private isSystemManagedRoute(merged: interfaces.data.IMergedRoute): boolean {
return merged.origin !== 'api';
}
async firstUpdated() { async firstUpdated() {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null); await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);

View File

@@ -1,273 +1,72 @@
# @serve.zone/dcrouter-web # @serve.zone/dcrouter-web
Web-based Operations Dashboard for DcRouter. 🖥️ Browser UI package for dcrouter's operations dashboard. 🖥️
A modern, reactive web application for monitoring and managing your DcRouter instance in real-time. Built with web components using [@design.estate/dees-element](https://code.foss.global/design.estate/dees-element) and [@design.estate/dees-catalog](https://code.foss.global/design.estate/dees-catalog). This package contains the browser entrypoint, app state, router, and web components that power the Ops dashboard served by dcrouter.
## Issue Reporting and Security ## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly. For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## Features ## What Is In Here
### 🔐 Secure Authentication | Path | Purpose |
- JWT-based login with persistent sessions (IndexedDB) | --- | --- |
- Automatic session expiry detection and cleanup | `index.ts` | Browser entrypoint that initializes routing and renders `<ops-dashboard>` |
- Secure username/password authentication | `appstate.ts` | Central reactive state and action definitions |
| `router.ts` | URL-based dashboard routing |
| `elements/` | Dashboard views and reusable UI pieces |
### 📊 Overview Dashboard ## Main Views
- Real-time server statistics (CPU, memory, uptime)
- Active connection counts and email throughput
- DNS query metrics and RADIUS session tracking
- Auto-refreshing with configurable intervals
### 🌐 Network View The dashboard currently includes views for:
- Active connection monitoring with real-time data from SmartProxy
- Top connected IPs with connection counts and percentages
- Throughput rates (inbound/outbound in kbit/s, Mbit/s, Gbit/s)
- Traffic chart with selectable time ranges
### 📧 Email Management - overview and configuration
- **Queued** — Emails pending delivery with queue position - network activity and route management
- **Sent** — Successfully delivered emails with timestamps - source profiles, target profiles, and network targets
- **Failed** — Failed emails with resend capability - email activity and email domains
- **Security** — Security incidents from email processing - DNS providers, domains, DNS records, and certificates
- Bounce record management and suppression list controls - API tokens and users
- VPN, remote ingress, logs, and security views
### 🔐 Certificate Management ## Route Management UX
- Domain-centric certificate overview with status indicators
- Certificate source tracking (ACME, provision function, static)
- Expiry date monitoring and alerts
- Per-domain backoff status for failed provisions
- One-click reprovisioning per domain
- Certificate import, export, and deletion
### 🌍 Remote Ingress Management The web UI reflects dcrouter's current route ownership model:
- Edge node registration with name, ports, and tags
- Real-time connection status (connected/disconnected/disabled)
- Public IP and active tunnel count per edge
- Auto-derived port display with manual/derived breakdown
- **Connection token generation** — one-click "Copy Token" for easy edge provisioning
- Enable/disable, edit, secret regeneration, and delete actions
### 🔐 VPN Management - system routes are shown separately from user routes
- VPN server status with forwarding mode, subnet, and WireGuard port - system routes are visible and toggleable
- Client registration table with create, enable/disable, and delete actions - system routes are not directly editable or deletable
- WireGuard config download, clipboard copy, and **QR code display** on client creation - API routes are fully managed through the route-management forms
- QR code export for existing clients — scan with WireGuard mobile app (iOS/Android)
- Per-client telemetry (bytes sent/received, keepalives)
- Server public key display for manual client configuration
### 📜 Log Viewer ## How It Talks To dcrouter
- Real-time log streaming
- Filter by log level (error, warning, info, debug)
- Search and time-range selection
### 🛣️ Route & API Token Management The frontend uses TypedRequest and shared interfaces from `@serve.zone/dcrouter-interfaces`.
- Programmatic route CRUD with enable/disable and override controls
- API token creation, revocation, and scope management
- Routes tab and API Tokens tab in unified view
### 🛡️ Security Profiles & Network Targets State actions in `appstate.ts` fetch and mutate:
- Create, edit, and delete reusable security profiles (IP allow/block lists, rate limits, max connections)
- Create, edit, and delete reusable network targets (host:port destinations)
- In-row and context menu actions for quick editing
- Changes propagate automatically to all referencing routes
### ⚙️ Configuration - stats and health
- Read-only display of current system configuration - logs
- Status badges for boolean values (enabled/disabled) - routes and tokens
- Array values displayed as pills with counts - certificates and ACME config
- Section icons and formatted byte/time values - DNS providers, domains, and records
- email domains and email operations
- VPN, remote ingress, and RADIUS data
### 🛡️ Security Dashboard ## Development Notes
- IP reputation monitoring
- Rate limit status across domains
- Blocked connection tracking
- Security event timeline
## Architecture The browser bundle is built from this package and served by the main dcrouter package.
### Technology Stack
| Layer | Package | Purpose |
|-------|---------|---------|
| **Components** | `@design.estate/dees-element` | Web component framework (lit-element based) |
| **UI Kit** | `@design.estate/dees-catalog` | Pre-built components (tables, charts, forms, app shell) |
| **State** | `@push.rocks/smartstate` | Reactive state management with persistent/soft modes |
| **Routing** | Client-side router | URL-synchronized view navigation |
| **API** | `@api.global/typedrequest` | Type-safe communication with OpsServer |
| **Types** | `@serve.zone/dcrouter-interfaces` | Shared TypedRequest interface definitions |
### Component Structure
```
ts_web/
├── index.ts # Entry point — renders <ops-dashboard>
├── appstate.ts # State management (all state parts + actions)
├── router.ts # Client-side routing (AppRouter)
├── plugins.ts # Dependency imports
└── elements/
├── ops-dashboard.ts # Main app shell
├── ops-view-overview.ts # Overview statistics
├── ops-view-network.ts # Network monitoring
├── ops-view-emails.ts # Email queue management
├── ops-view-certificates.ts # Certificate overview & reprovisioning
├── ops-view-remoteingress.ts # Remote ingress edge management
├── ops-view-vpn.ts # VPN client management
├── ops-view-logs.ts # Log viewer
├── ops-view-routes.ts # Route & API token management
├── ops-view-config.ts # Configuration display
├── ops-view-security.ts # Security dashboard
└── shared/
├── css.ts # Shared styles
└── ops-sectionheading.ts # Section heading component
```
### State Management
The app uses `@push.rocks/smartstate` v2.3+ with multiple state parts, scheduled actions with `autoPause: 'visibility'`, and batched updates:
| State Part | Mode | Description |
|-----------|------|-------------|
| `loginStatePart` | Persistent (IndexedDB) | JWT identity and login status |
| `statsStatePart` | Soft (memory) | Server, email, DNS, security, RADIUS, VPN metrics |
| `configStatePart` | Soft | Current system configuration |
| `uiStatePart` | Soft | Active view, sidebar, auto-refresh, theme |
| `logStatePart` | Soft | Recent logs, streaming status, filters |
| `networkStatePart` | Soft | Connections, IPs, throughput rates |
| `emailOpsStatePart` | Soft | Email queues, bounces, suppression list |
| `certificateStatePart` | Soft | Certificate list, summary, loading state |
| `remoteIngressStatePart` | Soft | Edge list, statuses, new edge secret |
| `vpnStatePart` | Soft | VPN clients, server status, new client config |
### Tab Visibility Optimization
The dashboard automatically pauses all background activity when the browser tab is hidden and resumes when visible:
- **Auto-refresh polling** uses `createScheduledAction` with `autoPause: 'visibility'` — stops HTTP requests while the tab is sleeping
- **In-flight guard** prevents concurrent refresh requests from piling up
- **WebSocket connection** disconnects when hidden and reconnects when visible, preventing log entry accumulation
- **Network traffic timer** pauses chart updates when the tab is backgrounded
- **Log entry batching** — incoming WebSocket log pushes are buffered and flushed once per animation frame to avoid per-entry re-renders
### Actions
```typescript
// Authentication
loginAction(username, password) // JWT login
logoutAction() // Clear session
// Data fetching (auto-refresh compatible)
fetchAllStatsAction() // Server + email + DNS + security stats
fetchConfigurationAction() // System configuration
fetchRecentLogsAction() // Log entries
fetchNetworkStatsAction() // Connection + throughput data
// Email operations
fetchQueuedEmailsAction() // Pending emails
fetchSentEmailsAction() // Delivered emails
fetchFailedEmailsAction() // Failed emails
fetchSecurityIncidentsAction() // Security events
fetchBounceRecordsAction() // Bounce records
resendEmailAction(emailId) // Re-queue failed email
removeFromSuppressionAction(email) // Remove from suppression list
// Certificates
fetchCertificateOverviewAction() // All certificates with summary
reprovisionCertificateAction(domain) // Reprovision a certificate
deleteCertificateAction(domain) // Delete a certificate
importCertificateAction(cert) // Import a certificate
fetchCertificateExport(domain) // Export (standalone function)
// Remote Ingress
fetchRemoteIngressAction() // Edges + statuses
createRemoteIngressAction(data) // Create new edge
updateRemoteIngressAction(data) // Update edge settings
deleteRemoteIngressAction(id) // Remove edge
regenerateRemoteIngressSecretAction(id) // New secret
toggleRemoteIngressAction(id, enabled) // Enable/disable
clearNewEdgeSecretAction() // Dismiss secret banner
fetchConnectionToken(edgeId) // Get connection token (standalone function)
// VPN
fetchVpnAction() // Clients + server status
createVpnClientAction(data) // Create new VPN client
deleteVpnClientAction(clientId) // Remove VPN client
toggleVpnClientAction(id, enabled) // Enable/disable
clearNewClientConfigAction() // Dismiss config banner
```
### Client-Side Routing
```
/overview → Overview dashboard
/network → Network monitoring
/emails → Email management
/emails/queued → Queued emails
/emails/sent → Sent emails
/emails/failed → Failed emails
/emails/security → Security incidents
/certificates → Certificate management
/remoteingress → Remote ingress edge management
/vpn → VPN client management
/routes → Route & API token management
/logs → Log viewer
/configuration → System configuration
/security → Security dashboard
```
URL state is synchronized with the UI — bookmarking and deep linking fully supported.
## Development
### Running Locally
Start DcRouter with OpsServer enabled:
```typescript
import { DcRouter } from '@serve.zone/dcrouter';
const router = new DcRouter({
// OpsServer starts automatically on port 3000
smartProxyConfig: { routes: [/* your routes */] }
});
await router.start();
// Dashboard at http://localhost:3000
```
### Building
```bash ```bash
# Build the bundle
pnpm run bundle pnpm run bundle
# Watch for development (auto-rebuild + restart)
pnpm run watch pnpm run watch
``` ```
The bundle is output to `./dist_serve/bundle.js` and served by the OpsServer. The generated bundle is written into `dist_serve/` by the main build pipeline.
### Adding a New View ## When To Use This Package
1. Create a view component in `elements/`: - Use it if you want the dashboard frontend as a package/module boundary.
```typescript - Use the main `@serve.zone/dcrouter` package if you want the server that actually serves this UI.
import { DeesElement, customElement, html, css } from '@design.estate/dees-element';
@customElement('ops-view-myview')
export class OpsViewMyView extends DeesElement {
public static styles = [css`:host { display: block; padding: 24px; }`];
public render() {
return html`<ops-sectionheading>My View</ops-sectionheading>`;
}
}
```
2. Add it to the dashboard tabs in `ops-dashboard.ts`
3. Add the route in `router.ts`
4. Add any state management in `appstate.ts`
## License and Legal Information ## License and Legal Information