newest
', + 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 TypedRequestnewest
', + 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(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index f9f1930..74c6bb3 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.18.0', + version: '13.19.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index cb43738..14466f3 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -30,7 +30,8 @@ import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/ import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js'; import { DnsManager } from './dns/manager.dns.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 { /** 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 private seedConfigRoutes: 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[] = []; // Environment access @@ -588,13 +590,15 @@ export class DcRouter { this.tunnelManager.syncAllowedEdges(); } }, - () => this.runtimeDnsRoutes, + undefined, + (storedRoute: IRoute) => this.hydrateStoredRouteForRuntime(storedRoute), ); this.apiTokenManager = new ApiTokenManager(); await this.apiTokenManager.initialize(); await this.routeConfigManager.initialize( this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], + this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], ); await this.targetProfileManager.normalizeAllRouteRefs(); @@ -912,10 +916,12 @@ export class DcRouter { logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) }); } + this.seedDnsRoutes = []; this.runtimeDnsRoutes = []; if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) { - this.runtimeDnsRoutes = this.generateDnsRoutes(); - logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.runtimeDnsRoutes) }); + this.seedDnsRoutes = this.generateDnsRoutes({ includeSocketHandler: false }); + 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) @@ -1338,19 +1344,20 @@ export class DcRouter { /** * 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) { return []; } - + + const includeSocketHandler = options?.includeSocketHandler !== false; const dnsRoutes: plugins.smartproxy.IRouteConfig[] = []; - + // Create routes for DNS-over-HTTPS paths const dohPaths = ['/dns-query', '/resolve']; - + // Use the first nameserver domain for DoH routes const primaryNameserver = this.options.dnsNsDomains[0]; - + for (const path of dohPaths) { const dohRoute: plugins.smartproxy.IRouteConfig = { name: `dns-over-https-${path.replace('/', '')}`, @@ -1359,18 +1366,42 @@ export class DcRouter { domains: [primaryNameserver], path: path }, - action: { - type: 'socket-handler' as any, - socketHandler: this.createDnsSocketHandler() - } as any + action: includeSocketHandler + ? { + type: 'socket-handler' as any, + socketHandler: this.createDnsSocketHandler() + } as any + : { + type: 'socket-handler' as any, + } as any }; - + dnsRoutes.push(dohRoute); } - + 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) * @param domain The domain to check @@ -1939,37 +1970,20 @@ export class DcRouter { for (const domainConfig of internalDnsDomains) { const domain = domainConfig.domain; const ttl = domainConfig.dns?.internal?.ttl || 3600; - const mxPriority = domainConfig.dns?.internal?.mxPriority || 10; - - // MX record - points to the domain itself for email handling - records.push({ - name: domain, - type: 'MX', - value: `${mxPriority} ${domain}`, - ttl - }); - - // SPF record - using sensible defaults - const spfRecord = 'v=spf1 a mx ~all'; - 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 + const requiredRecords = buildEmailDnsRecords({ + domain, + hostname: this.options.emailConfig.hostname, + mxPriority: domainConfig.dns?.internal?.mxPriority, + }).filter((record) => !record.name.includes('._domainkey.')); + + for (const record of requiredRecords) { + records.push({ + name: record.name, + type: record.type, + value: record.value, + ttl, + }); + } } logger.log('info', `Generated ${records.length} email DNS records for ${internalDnsDomains.length} internal-dns domains`); diff --git a/ts/config/classes.route-config-manager.ts b/ts/config/classes.route-config-manager.ts index 3189787..5839572 100644 --- a/ts/config/classes.route-config-manager.ts +++ b/ts/config/classes.route-config-manager.ts @@ -14,6 +14,11 @@ import type { ReferenceResolver } from './classes.reference-resolver.js'; /** An IP allow entry: plain IP/CIDR or domain-scoped. */ export type TIpAllowEntry = string | { ip: string; domains: string[] }; +export interface IRouteMutationResult { + success: boolean; + message?: string; +} + /** * Simple async mutex β serializes concurrent applyRoutes() calls so the Rust engine * never receives rapid overlapping route updates that can churn UDP/QUIC listeners. @@ -56,6 +61,7 @@ export class RouteConfigManager { private referenceResolver?: ReferenceResolver, private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void, private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[], + private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined, ) {} /** Expose routes map for reference resolution lookups. */ @@ -63,6 +69,10 @@ export class RouteConfigManager { return this.routes; } + public getRoute(id: string): IRoute | undefined { + return this.routes.get(id); + } + /** * Load persisted routes, seed serializable config/email/dns routes, * compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy. @@ -94,6 +104,7 @@ export class RouteConfigManager { id: route.id, enabled: route.enabled, origin: route.origin, + systemKey: route.systemKey, createdAt: route.createdAt, updatedAt: route.updatedAt, metadata: route.metadata, @@ -153,9 +164,21 @@ export class RouteConfigManager { enabled?: boolean; metadata?: PartialOrigin: ${merged.origin}
Status: ${merged.enabled ? 'Enabled' : 'Disabled'}
ID: ${merged.id}
This route is system-managed. Change its source config to modify it directly.
` : ''} ${meta?.sourceProfileName ? html`Source Profile: ${meta.sourceProfileName}
` : ''} ${meta?.networkTargetName ? html`Network Target: ${meta.networkTargetName}
` : ''} @@ -304,25 +303,29 @@ export class OpsViewRoutes extends DeesElement { await modalArg.destroy(); }, }, - { - name: 'Edit', - iconName: 'lucide:pencil', - action: async (modalArg: any) => { - await modalArg.destroy(); - this.showEditRouteDialog(merged); - }, - }, - { - name: 'Delete', - iconName: 'lucide:trash-2', - action: async (modalArg: any) => { - await appstate.routeManagementStatePart.dispatchAction( - appstate.deleteRouteAction, - merged.id, - ); - await modalArg.destroy(); - }, - }, + ...(!isSystemManaged + ? [ + { + name: 'Edit', + iconName: 'lucide:pencil', + action: async (modalArg: any) => { + await modalArg.destroy(); + this.showEditRouteDialog(merged); + }, + }, + { + name: 'Delete', + iconName: 'lucide:trash-2', + action: async (modalArg: any) => { + await appstate.routeManagementStatePart.dispatchAction( + appstate.deleteRouteAction, + merged.id, + ); + await modalArg.destroy(); + }, + }, + ] + : []), { name: 'Close', iconName: 'lucide:x', @@ -336,10 +339,9 @@ export class OpsViewRoutes extends DeesElement { const clickedRoute = e.detail; if (!clickedRoute) return; - const merged = this.routeState.mergedRoutes.find( - (mr) => mr.route.name === clickedRoute.name, - ); + const merged = this.findMergedRoute(clickedRoute); if (!merged) return; + if (this.isSystemManagedRoute(merged)) return; this.showEditRouteDialog(merged); } @@ -348,10 +350,9 @@ export class OpsViewRoutes extends DeesElement { const clickedRoute = e.detail; if (!clickedRoute) return; - const merged = this.routeState.mergedRoutes.find( - (mr) => mr.route.name === clickedRoute.name, - ); + const merged = this.findMergedRoute(clickedRoute); if (!merged) return; + if (this.isSystemManagedRoute(merged)) return; const { DeesModal } = await import('@design.estate/dees-catalog'); await DeesModal.createAndShow({ @@ -675,6 +676,23 @@ export class OpsViewRoutes extends DeesElement { 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() { await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null); diff --git a/ts_web/readme.md b/ts_web/readme.md index 791c802..f976886 100644 --- a/ts_web/readme.md +++ b/ts_web/readme.md @@ -1,273 +1,72 @@ # @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 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 -- JWT-based login with persistent sessions (IndexedDB) -- Automatic session expiry detection and cleanup -- Secure username/password authentication +| Path | Purpose | +| --- | --- | +| `index.ts` | Browser entrypoint that initializes routing and renders `