import * as plugins from './plugins.js'; import * as paths from './paths.js'; // Certificate types are available via plugins.tsclass // Import the email server and its configuration from smartmta import { type Email, UnifiedEmailServer, type IUnifiedEmailServerOptions, type IEmailRoute, type IEmailDomainConfig, type IStorageManagerLike, type IExtendedSmtpSession, type IMessageAcceptanceContext, type IMessageAcceptanceDecision, } from '@push.rocks/smartmta'; import { logger, logBuffer } from './logger.js'; import { StorageBackedCertManager } from './classes.storage-cert-manager.js'; import { CertProvisionScheduler } from './classes.cert-provision-scheduler.js'; // Import unified database import { DcRouterDb, type IDcRouterDbConfig, CacheCleaner, ProxyCertDoc, AcmeCertDoc, CachedEmail } from './db/index.js'; // Import migration runner and app version import { createMigrationRunner } from '../ts_migrations/index.js'; import { commitinfo } from './00_commitinfo_data.js'; import { OpsServer } from './opsserver/index.js'; import { MetricsManager, OpsEventManager } from './monitoring/index.js'; import { RadiusServer, type IRadiusServerConfig } from './radius/index.js'; import { RemoteIngressHubLifecycle, RemoteIngressManager, TunnelManager, buildEdgeOnlyDerivationRoutes } from './remoteingress/index.js'; import { VpnAccessResolver, VpnManager, type IVpnManagerConfig } from './vpn/index.js'; import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager, buildHttpRedirectRuntimeRoutes, collectAutoCertificateHostnames } from './config/index.js'; import type { TVpnClientAllowEntry } from './config/classes.route-config-manager.js'; import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager, RoutePolicyAugmenter, AuthenticationEventManager } from './security/index.js'; import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js'; import { applyDefaultInboundPolicy } from './email/inbound-policy.js'; import { DnsManager } from './dns/manager.dns.js'; import { DnsAuthorityManager } from './dns/manager.dns-authority.js'; import { DnsServerRuntime } from './dns/classes.dns-server-runtime.js'; import { GatewayRouteDnsReconciler } from './dns/classes.gateway-route-dns-reconciler.js'; import { DomainOwnershipError } from './dns/domain-ownership.js'; import { AcmeConfigManager } from './acme/manager.acme-config.js'; import { SmartAcmeLifecycle } from './acme/classes.smartacme-lifecycle.js'; import { AcmePermanentFailureError, classifyAcmeFailure } from './acme/acme-failure-classification.js'; import { AcceptedEmailSpool, EmailDomainManager, EmailRouteBuilder, EmailSettingsManager, GatewayMailDomainStore, MailDnsSync, MailEdgeEligibility, MailEgressCoordinator, ReleasedRemoteIngressEgressIdentitySource, SmartMtaBlobStorageManager, SmartMtaStorageManager, SmtpAccountManager, WorkAppMailManager, type ISmartMtaBlobStorageConfig, type TSmartMtaQueueItemLike } from './email/index.js'; import { WebPushManager } from './webpush/index.js'; import type { IRoute } from '../ts_interfaces/data/route-management.js'; import type { IEmailOutboundEgressStatus, IEmailPortConfig, IEmailServerSettings, IEmailServerSettingsSeed, TEmailOutboundMode, TEmailServerSettingsUpdate } from '../ts_interfaces/data/email-settings.js'; import type { IDcRouterRouteConfig, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate } from '../ts_interfaces/data/remoteingress.js'; import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js'; /** * dcrouter's superset of SmartMTA's SMTP TLS options. * * SmartMTA consumes PEM content (`certPem`/`keyPem`). Deployments configure * certificate FILES, so dcrouter accepts the path form too and loads it eagerly * at startup — a path that cannot be read fails startup rather than silently * leaving the listener without TLS. */ export interface IDcRouterEmailTlsConfig extends NonNullable { /** Path to the certificate chain PEM file. Requires keyPath. */ certPath?: string; /** Path to the private key PEM file. Requires certPath. */ keyPath?: string; } /** dcrouter's email server options: SmartMTA's, with the path-shaped tls block. */ export interface IDcRouterEmailConfig extends Omit { tls?: IDcRouterEmailTlsConfig; } /** Convention-correct alias for dcrouter's email server options. */ export type TDcRouterEmailConfig = IDcRouterEmailConfig; /** Whether an email configuration carries any SMTP AUTH credentials. */ const emailConfigHasAuth = (emailConfigArg: IUnifiedEmailServerOptions | undefined): boolean => !!emailConfigArg?.auth?.required || !!emailConfigArg?.auth?.users?.length || !!emailConfigArg?.auth?.accounts?.length; export interface IDcRouterOptions { /** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */ baseDir?: string; /** * Direct CoreTraffic configuration - gives full control over HTTP/HTTPS and TCP/SNI traffic * This is the preferred way to configure HTTP/HTTPS and general TCP/SNI traffic */ coreTrafficConfig?: plugins.smartproxy.ISmartProxyOptions; /** Legacy name for coreTrafficConfig. */ smartProxyConfig?: plugins.smartproxy.ISmartProxyOptions; /** * Email server configuration * This enables all email handling with pattern-based routing */ emailConfig?: TDcRouterEmailConfig; /** SmartBucket configuration for durable SmartMTA queue and attachment blobs. */ emailBlobStorage?: ISmartMtaBlobStorageConfig; /** Outbound SMTP connection mode. Defaults to direct unless DB settings override it. */ emailOutboundMode?: TEmailOutboundMode; /** * Custom email port configuration * Allows configuring specific ports for email handling * This overrides the default port mapping in the emailConfig */ emailPortConfig?: IEmailPortConfig; /** TLS/certificate configuration */ tls?: { /** Contact email for ACME certificates */ contactEmail: string; /** Domain for main certificate */ domain?: string; /** Path to certificate file (if not using auto-provisioning) */ certPath?: string; /** Path to key file (if not using auto-provisioning) */ keyPath?: string; /** Path to CA certificate file (for custom CAs) */ caPath?: string; }; /** * The nameserver domains (e.g., ['ns1.example.com', 'ns2.example.com']) * These will automatically get A records pointing to publicIp or proxyIps[0] * A zone becomes authoritative by having its public NS records observed * naming one of these — that is the only way into the authority set. * * There is deliberately no `dnsScopes` counterpart. Which zones dcrouter * answers for is database state carrying its own delegation evidence, not * deployment configuration; see ts/dns/manager.dns-authority.ts. */ dnsNsDomains?: string[]; /** Explicit UDP bind address for the embedded DNS server. Defaults to auto-detection. */ dnsBindInterface?: string; /** * IPs of proxies that forward traffic to your server (optional) * When defined AND useIngressProxy is true, A records with server IP are replaced with proxy IPs * If not defined or empty, all A records use the real server IP * Helps hide real server IP for security/privacy */ proxyIps?: string[]; /** * Public IP address for nameserver A records (required if proxyIps not set) * This is the IP that will be used for the nameserver domains (dnsNsDomains) * If proxyIps is set, the first proxy IP will be used instead */ publicIp?: string; /** * DNS records to register * Must be within a delegation-verified zone (or receive warning) * Only need A, CNAME, TXT, MX records (NS records auto-generated, SOA handled by smartdns) * Can use `useIngressProxy: false` to expose real server IP (defaults to true) */ dnsRecords?: Array<{ name: string; type: 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'NS' | 'SOA'; value: string; ttl?: number; useIngressProxy?: boolean; // Whether to replace server IP with proxy IP (default: true) }>; /** * Unified database configuration. * All persistent data (config, certs, VPN, cache, etc.) is stored via smartdata. * If mongoDbUrl is provided, connects to external MongoDB. * Otherwise, starts an embedded LocalSmartDb automatically. */ dbConfig?: { /** Enable database (default: true). Set to false in tests to skip DB startup. */ enabled?: boolean; /** External MongoDB connection URL. If absent, uses embedded LocalSmartDb. */ mongoDbUrl?: string; /** Storage path for embedded database data (default: ~/.serve.zone/dcrouter/tsmdb) */ storagePath?: string; /** Database name (default: dcrouter) */ dbName?: string; /** Cache cleanup interval in hours (default: 1) */ cleanupIntervalHours?: number; /** Seed default security profiles and network targets when DB is empty on first startup. */ seedOnEmpty?: boolean; /** Custom seed data for profiles and targets (overrides built-in defaults). */ seedData?: import('./config/classes.db-seeder.js').ISeedData; }; /** * RADIUS server configuration for network authentication * Enables MAC Authentication Bypass (MAB) and VLAN assignment */ radiusConfig?: IRadiusServerConfig; /** * Remote Ingress configuration for edge tunnel nodes * Enables edge nodes to accept incoming connections and tunnel them to this DcRouter */ /** * HTTP/3 (QUIC) configuration for HTTPS routes. * Enabled by default — qualifying HTTPS routes on port 443 are automatically * augmented with QUIC/H3 fields. Set { enabled: false } to disable globally. * Individual routes can opt out via action.options.http3 = false. */ http3?: IHttp3Config; /** Port for the OpsServer web UI (default: 3000) */ opsServerPort?: number; /** Optional OpsServer account authentication settings. */ adminAuth?: { /** Optional idp.global password-authentication URL override. Defaults to the SDK's hosted https://idp.global endpoint. Can also be set through DCROUTER_IDP_GLOBAL_URL. */ idpGlobalUrl?: string; /** Test/integration hook for injecting an idp.global-compatible password client. */ idpClient?: Pick; }; remoteIngressConfig?: { /** Enable remote ingress hub (default: false) */ enabled?: boolean; /** Port for tunnel connections from edge nodes (default: 8443) */ tunnelPort?: number; /** External hostname of this hub, embedded in connection tokens */ hubDomain?: string; /** TLS configuration for the tunnel server */ tls?: { certPath?: string; keyPath?: string; }; /** Performance profile and limits for remote ingress hub/edge tunnels. */ performance?: import('../ts_interfaces/data/remoteingress.js').IRemoteIngressPerformanceConfig; }; /** * VPN server configuration. * Enables VPN-based access control: routes with vpnOnly are only * accessible from VPN clients whose TargetProfile matches. Supports WireGuard + native (WS/QUIC) transports. */ vpnConfig?: { /** Enable VPN server (default: false) */ enabled?: boolean; /** VPN subnet CIDR (default: '10.8.0.0/24') */ subnet?: string; /** WireGuard UDP listen port (default: 51820) */ wgListenPort?: number; /** DNS servers pushed to VPN clients */ dns?: string[]; /** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */ serverEndpoint?: string; /** Pre-defined VPN clients created on startup */ clients?: Array<{ clientId: string; targetProfileIds?: string[]; description?: string; }>; /** Destination routing policy for VPN client traffic. * Default in socket mode: { default: 'forceTarget', target: '127.0.0.1' } (all traffic → SmartProxy). * Default in tun mode: not set (all traffic passes through). */ destinationPolicy?: { default: 'forceTarget' | 'block' | 'allow'; target?: string; allowList?: string[]; blockList?: string[]; }; /** Forwarding mode: 'socket' (default, userspace NAT), 'bridge' (L2 bridge to host LAN), * or 'hybrid' (socket default, bridge for clients with useHostIp=true) */ forwardingMode?: 'socket' | 'bridge' | 'hybrid'; /** LAN subnet CIDR for bridge mode (e.g., '192.168.1.0/24') */ bridgeLanSubnet?: string; /** Physical network interface for bridge mode (auto-detected if omitted) */ bridgePhysicalInterface?: string; /** Start of VPN client IP range in LAN subnet (host offset, default: 200) */ bridgeIpRangeStart?: number; /** End of VPN client IP range in LAN subnet (host offset, default: 250) */ bridgeIpRangeEnd?: number; }; } /** * DcRouter can be run on ingress and egress to and from a datacenter site. */ /** * Context passed to HTTP routing rules */ /** * Context passed to port proxy (SmartProxy) routing rules */ export interface PortProxyRuleContext { proxy: plugins.smartproxy.SmartProxy; routes: plugins.smartproxy.IRouteConfig[]; } export class DcRouter { public options: IDcRouterOptions; public resolvedPaths: ReturnType; // Core services public smartProxy?: plugins.smartproxy.SmartProxy; public smartAcme?: plugins.smartacme.SmartAcme; public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer; public emailServer?: UnifiedEmailServer; public radiusServer?: RadiusServer; public opsServer!: OpsServer; private opsServerStopped = false; public metricsManager?: MetricsManager; private emailEventSubscriptions: Array<{ emitter: { off(eventName: string, listener: (...args: any[]) => void): void }; eventName: string; listener: (...args: any[]) => void; }> = []; public storageManager!: IStorageManagerLike; private smartMtaBlobStorageManager?: SmartMtaBlobStorageManager; private databaseMigrationsReady = false; public getSmartMtaBlobStorageManager(): SmartMtaBlobStorageManager | undefined { return this.smartMtaBlobStorageManager; } // Unified database (smartdata + LocalSmartDb or external MongoDB) public dcRouterDb?: DcRouterDb; public cacheCleaner?: CacheCleaner; // Remote Ingress public remoteIngressManager?: RemoteIngressManager; public tunnelManager?: TunnelManager; private smartProxyLifecycleChain: Promise = Promise.resolve(); private emailLifecycleChain: Promise = Promise.resolve(); // VPN public vpnManager?: VpnManager; // Programmatic config API public routeConfigManager?: RouteConfigManager; public apiTokenManager?: ApiTokenManager; public gatewayClientManager?: GatewayClientManager; public referenceResolver?: ReferenceResolver; public targetProfileManager?: TargetProfileManager; // Domain / DNS management (DB-backed providers, domains, records) public dnsManager?: DnsManager; /** Owns which zones dcrouter may answer for authoritatively, and proves it. */ public dnsAuthorityManager?: DnsAuthorityManager; public gatewayRouteDnsReconciler?: GatewayRouteDnsReconciler; // Durable, acknowledgeable platform configuration events (DB-backed) public opsEventManager?: OpsEventManager; // ACME configuration (DB-backed singleton, replaces tls.contactEmail) public acmeConfigManager?: AcmeConfigManager; public emailSettingsManager?: EmailSettingsManager; public emailDomainManager?: EmailDomainManager; public gatewayMailDomainStore: GatewayMailDomainStore; public workAppMailManager: WorkAppMailManager; /** Single composition owner of the email runtime auth block and route list. */ public smtpAccountManager: SmtpAccountManager; public acceptedEmailSpool: AcceptedEmailSpool; public mailEgressCoordinator: MailEgressCoordinator; public mailDnsSync: MailDnsSync; public mailEdgeEligibility: MailEdgeEligibility; public securityPolicyManager?: SecurityPolicyManager; public authenticationEventManager: AuthenticationEventManager; // Auto-discovered public IP (populated by generateAuthoritativeRecords) public detectedPublicIp: string | null = null; // DNS query logging rate limiter state // Certificate status tracking from SmartProxy events (keyed by domain) public certificateStatusMap = new Map(); // Certificate provisioning scheduler with per-domain backoff public certProvisionScheduler?: CertProvisionScheduler; // Service lifecycle management public serviceManager: plugins.taskbuffer.ServiceManager; private serviceSubjectSubscription?: plugins.smartrx.rxjs.Subscription; public smartAcmeLifecycle: SmartAcmeLifecycle; public vpnAccessResolver: VpnAccessResolver; public remoteIngressHubLifecycle: RemoteIngressHubLifecycle; public dnsServerRuntime: DnsServerRuntime; public emailRouteBuilder: EmailRouteBuilder; public routePolicyAugmenter: RoutePolicyAugmenter; public webPushManager: WebPushManager; // TypedRouter for API endpoints public typedrouter = new plugins.typedrequest.TypedRouter(); // Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = []; public seedEmailRoutes: IDcRouterRouteConfig[] = []; 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 private qenv = new plugins.qenv.Qenv('./', '.nogit/'); constructor(optionsArg: IDcRouterOptions) { const coreTrafficConfig = optionsArg.coreTrafficConfig || optionsArg.smartProxyConfig; // Set defaults in options this.options = { ...optionsArg, coreTrafficConfig, smartProxyConfig: coreTrafficConfig, }; // Authentication handlers and metrics share this single bounded event owner. this.authenticationEventManager = new AuthenticationEventManager(this); // Capture smartmta's own log stream into the ops log buffer — without // this, mailer bridge crashes and delivery failures inside smartmta are // invisible to `getLogs` and postmortems. plugins.smartmta.baseLogger.addLogDestination(logBuffer); // Resolve all data paths from baseDir this.resolvedPaths = paths.resolvePaths(this.options.baseDir); this.gatewayMailDomainStore = new GatewayMailDomainStore(); this.workAppMailManager = new WorkAppMailManager(this); this.smtpAccountManager = new SmtpAccountManager(this); this.acceptedEmailSpool = new AcceptedEmailSpool(this); const mailEgressIdentitySource = new ReleasedRemoteIngressEgressIdentitySource(this); this.mailEdgeEligibility = new MailEdgeEligibility(this, mailEgressIdentitySource); this.mailEgressCoordinator = new MailEgressCoordinator(this, this.mailEdgeEligibility); this.mailDnsSync = new MailDnsSync(this, this.mailEdgeEligibility); this.smartAcmeLifecycle = new SmartAcmeLifecycle(this); this.vpnAccessResolver = new VpnAccessResolver(this); this.remoteIngressHubLifecycle = new RemoteIngressHubLifecycle(this); this.dnsServerRuntime = new DnsServerRuntime(this); this.emailRouteBuilder = new EmailRouteBuilder(this); this.routePolicyAugmenter = new RoutePolicyAugmenter(this); // Construct before OpsServer so early handlers can report unavailable safely. // Secret reads remain deferred until WebPushProvider.start(). this.webPushManager = new WebPushManager(); // Initialize service manager and register all services this.serviceManager = new plugins.taskbuffer.ServiceManager({ name: 'dcrouter', startupTimeoutMs: 0, shutdownTimeoutMs: 30_000, }); this.registerServices(); } /** * Register all dcrouter services with the ServiceManager. * Services are started in dependency order, with failure isolation for optional services. */ private registerServices(): void { // OpsServer: critical, no dependencies — provides visibility this.serviceManager.addService( new plugins.taskbuffer.Service('OpsServer') .critical() .withStart(async () => { this.opsServerStopped = false; this.opsServer = new OpsServer(this); await this.opsServer.start(); }) .withStop(async () => { await this.stopOpsServer(); }) .withRetry({ maxRetries: 0 }), ); // DcRouterDb: critical when enabled, no dependencies — unified database for all persistence if (this.options.dbConfig?.enabled !== false) { this.serviceManager.addService( new plugins.taskbuffer.Service('DcRouterDb') .critical() .withStart(async () => { await this.setupDcRouterDb(); }) .withStop(async () => { this.databaseMigrationsReady = false; if (this.cacheCleaner) { this.cacheCleaner.stop(); this.cacheCleaner = undefined; } if (this.dcRouterDb) { await this.dcRouterDb.stop(); DcRouterDb.resetInstance(); this.dcRouterDb = undefined; } }) .withRetry({ maxRetries: 2, baseDelayMs: 1000, maxDelayMs: 5000 }), ); } // Web Push provider: disabled by default. Enabled mode is critical and // depends on the migrated database; disabled mode reads no provider secrets. const webPushDependencies = this.options.dbConfig?.enabled === false ? [] : ['DcRouterDb']; this.serviceManager.addService( new plugins.taskbuffer.Service('WebPushProvider') .critical() .dependsOn(...webPushDependencies) .withStart(async () => { await this.webPushManager.start(); }) .withStop(async () => { await this.webPushManager.stop(); }) .withRetry({ maxRetries: 0 }), ); // Keep the database alive through the MetricsManager's final persistence drain. const metricsDependencies = ['OpsServer']; if (this.options.dbConfig?.enabled !== false) { metricsDependencies.push('DcRouterDb'); } // MetricsManager: optional, depends on OpsServer and the database when enabled. this.serviceManager.addService( new plugins.taskbuffer.Service('MetricsManager') .optional() .dependsOn(...metricsDependencies) .withStart(async () => { this.metricsManager = new MetricsManager(this); await this.metricsManager.start(); }) .withStop(async () => { if (this.metricsManager) { await this.metricsManager.stop(); this.metricsManager = undefined; } }) .withRetry({ maxRetries: 1, baseDelayMs: 1000 }), ); // OpsEventManager: optional, depends on DcRouterDb — durable acknowledgeable // platform configuration events (config conflicts, not runtime errors). if (this.options.dbConfig?.enabled !== false) { this.serviceManager.addService( new plugins.taskbuffer.Service('OpsEventManager') .optional() .dependsOn('DcRouterDb') .withStart(async () => { this.opsEventManager = new OpsEventManager(); await this.opsEventManager.start(); }) .withStop(async () => { if (this.opsEventManager) { await this.opsEventManager.stop(); this.opsEventManager = undefined; } }) .withRetry({ maxRetries: 1, baseDelayMs: 500 }), ); } // DnsManager: optional, depends on DcRouterDb — owns DB-backed DNS state // (providers, domains, records). Must run before SmartProxy so ACME DNS-01 // wiring can look up providers. if (this.options.dbConfig?.enabled !== false) { this.serviceManager.addService( new plugins.taskbuffer.Service('DnsManager') .optional() .dependsOn('DcRouterDb') .withStart(async () => { this.dnsAuthorityManager = new DnsAuthorityManager( () => this.options.dnsNsDomains || [], ); // Throws when the stored authority set is unreadable. That fails // this service; DnsServerRuntime.setup() independently refuses to // start on an 'unavailable' state, because dependsOn only orders // services and does not gate them. An unknown authority set must // never be served as an empty one. await this.dnsAuthorityManager.start(); this.dnsManager = new DnsManager(this.options); // Ownership proof reads the delegation-verified set, so a zone // verified at runtime counts immediately and a zone revoked at // runtime stops counting immediately. this.dnsManager.setAuthorityZonesResolver( () => this.dnsAuthorityManager?.getEffectiveZoneNames() || [], ); this.dnsAuthorityManager.setReconciler( async (reasonArg) => await this.reconcileDnsAuthority(reasonArg), ); await this.dnsManager.start(); }) .withStop(async () => { if (this.dnsManager) { await this.dnsManager.stop(); this.dnsManager = undefined; } if (this.dnsAuthorityManager) { await this.dnsAuthorityManager.stop(); this.dnsAuthorityManager = undefined; } }) .withRetry({ maxRetries: 1, baseDelayMs: 500 }), ); } // AcmeConfigManager: optional, depends on DcRouterDb — owns the singleton // ACME configuration (accountEmail, useProduction, etc.). Must run before // SmartProxy so setupSmartProxy() can read the ACME config from the DB. if (this.options.dbConfig?.enabled !== false) { this.serviceManager.addService( new plugins.taskbuffer.Service('AcmeConfigManager') .optional() .dependsOn('DcRouterDb') .withStart(async () => { this.acmeConfigManager = new AcmeConfigManager(); await this.acmeConfigManager.start(); }) .withStop(async () => { if (this.acmeConfigManager) { await this.acmeConfigManager.stop(); this.acmeConfigManager = undefined; } }) .withRetry({ maxRetries: 1, baseDelayMs: 500 }), ); } // SmtpAccountManager: optional, depends on DcRouterDb — loads operator // SMTP accounts into memory before EmailServer composes the auth block. // The instance itself is constructed unconditionally in the constructor // (composition must work with zero accounts when the DB is disabled); // this service only manages the DB load. if (this.options.dbConfig?.enabled !== false) { this.serviceManager.addService( new plugins.taskbuffer.Service('SmtpAccountManager') .optional() .dependsOn('DcRouterDb') .withStart(async () => { await this.smtpAccountManager.start(); }) .withStop(async () => { await this.smtpAccountManager.stop(); }) .withRetry({ maxRetries: 1, baseDelayMs: 500 }), ); } // Email Domain Manager: optional, depends on DcRouterDb if (this.options.dbConfig?.enabled !== false) { this.serviceManager.addService( new plugins.taskbuffer.Service('EmailDomainManager') .optional() .dependsOn('DcRouterDb', 'EmailSettingsManager') .withStart(async () => { this.emailDomainManager = new EmailDomainManager(this); await this.emailDomainManager.start(); }) .withStop(async () => { if (this.emailDomainManager) { await this.emailDomainManager.stop(); this.emailDomainManager = undefined; } }), ); } // EmailSettingsManager: optional, depends on DcRouterDb — owns the DB-backed // singleton email server config and projects it into runtime options before // SmartProxy and EmailDomainManager read email settings. if (this.options.dbConfig?.enabled !== false) { this.serviceManager.addService( new plugins.taskbuffer.Service('EmailSettingsManager') .optional() .dependsOn('DcRouterDb') .withStart(async () => { this.emailSettingsManager = new EmailSettingsManager(this.options); try { await this.emailSettingsManager.start(); } catch (error: unknown) { // Loud failure: without this manager, outbound mail defers (strict mode // resolution) — this log is the operator's pointer to the root cause. logger.log('error', `EmailSettingsManager failed to start — DB-backed email settings unavailable, outbound mail will defer: ${(error as Error).message}`); throw error; } }) .withStop(async () => { if (this.emailSettingsManager) { await this.emailSettingsManager.stop(); this.emailSettingsManager = undefined; } }) .withRetry({ maxRetries: 1, baseDelayMs: 500 }), ); } // SecurityPolicyManager: optional, depends on DcRouterDb — owns IP intelligence // and compiles the global block policy for SmartProxy and remote ingress edges. if (this.options.dbConfig?.enabled !== false) { this.serviceManager.addService( new plugins.taskbuffer.Service('SecurityPolicyManager') .optional() .dependsOn('DcRouterDb') .withStart(async () => { this.securityPolicyManager = new SecurityPolicyManager({ onPolicyChanged: () => this.applySecurityPolicy(), }); await this.securityPolicyManager.start(); }) .withStop(async () => { if (this.securityPolicyManager) { await this.securityPolicyManager.stop(); this.securityPolicyManager = undefined; } }) .withRetry({ maxRetries: 1, baseDelayMs: 500 }), ); } // RemoteIngressManager: optional, depends on DcRouterDb — owns DB-backed // hub settings and edge registrations. It starts before SmartProxy so // SmartProxy can use the DB-backed enabled flag for PROXY protocol setup. if (this.options.dbConfig?.enabled !== false) { this.serviceManager.addService( new plugins.taskbuffer.Service('RemoteIngressManager') .optional() .dependsOn('DcRouterDb') .withStart(async () => { this.remoteIngressManager = new RemoteIngressManager(); this.remoteIngressManager.setHubPublicIps([this.options.publicIp]); this.remoteIngressManager.setOnEdgesChanged((reason) => { this.handleRemoteIngressEdgesChanged(reason); }); await this.remoteIngressManager.initialize(); }) .withStop(async () => { this.remoteIngressManager = undefined; }) .withRetry({ maxRetries: 1, baseDelayMs: 500 }), ); } // SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled) const smartProxyDeps: string[] = []; if (this.options.dbConfig?.enabled !== false) { smartProxyDeps.push('DcRouterDb'); smartProxyDeps.push('DnsManager'); smartProxyDeps.push('AcmeConfigManager'); smartProxyDeps.push('EmailSettingsManager'); smartProxyDeps.push('SecurityPolicyManager'); smartProxyDeps.push('RemoteIngressManager'); } this.serviceManager.addService( new plugins.taskbuffer.Service('SmartProxy') .critical() .dependsOn(...smartProxyDeps) .withStart(async () => { await this.setupSmartProxy(); }) .withStop(async () => { await this.queueSmartProxyLifecycleTask(async () => { try { if (this.smartProxy) { const existingSmartProxy = this.smartProxy; existingSmartProxy.removeAllListeners(); await existingSmartProxy.stop(); if (this.smartProxy === existingSmartProxy) { this.smartProxy = undefined; } } } finally { await this.smartAcmeLifecycle.stop(); } }); }) .withRetry({ maxRetries: 0 }), ); // SmartAcme: optional, depends on SmartProxy — aggressive retry for rate limits. // Always registered when the DB is enabled; setupSmartProxy() decides whether // to actually instantiate SmartAcme based on whether any DnsProviderDoc exists. // If `this.smartAcme` is unset by the time this service starts, withStart is a no-op. if (this.options.dbConfig?.enabled !== false) { this.serviceManager.addService( new plugins.taskbuffer.Service('SmartAcme') .optional() .dependsOn('SmartProxy') .withStart(async () => { this.smartAcmeLifecycle.serviceStarted = true; this.smartAcmeLifecycle.startInBackground(); }) .withStop(async () => { this.smartAcmeLifecycle.serviceStarted = false; await this.smartAcmeLifecycle.stop(); }) .withRetry({ maxRetries: 0 }), ); } // ConfigManagers: optional, depends on SmartProxy + DcRouterDb // Requires DcRouterDb to be enabled (document classes need the database) if (this.options.dbConfig?.enabled !== false) { this.serviceManager.addService( new plugins.taskbuffer.Service('ConfigManagers') .optional() .dependsOn('SmartProxy', 'DcRouterDb') .withStart(async () => { // Initialize reference resolver first (profiles + targets) this.referenceResolver = new ReferenceResolver(); await this.referenceResolver.initialize(); // Initialize target profile manager this.targetProfileManager = new TargetProfileManager( () => this.routeConfigManager?.getRoutes() || new Map(), ); await this.targetProfileManager.initialize(); this.routeConfigManager = new RouteConfigManager( () => this.smartProxy, () => this.options.http3, this.vpnAccessResolver.createRouteAllowResolver(), this.referenceResolver, // Sync routes to RemoteIngressManager whenever routes change, // then push updated derived ports to the Rust hub binary async (routes) => { try { await this.remoteIngressHubLifecycle.updateRoutes([ ...(routes as IDcRouterRouteConfig[]), ...this.getEdgeOnlyDerivationRoutes(), ]); } catch (err: unknown) { logger.log('error', `Failed to sync Remote Ingress allowed edges: ${(err as Error).message}`); } try { await this.dnsServerRuntime.syncPrivateRouteOverrides( routes as IDcRouterRouteConfig[], ); } catch (err: unknown) { logger.log( 'error', `Failed to sync the DNS private-route overlay: ${(err as Error).message}`, ); } }, async (preparedRoutes) => { const runtimeRoutes = [ ...buildHttpRedirectRuntimeRoutes(preparedRoutes || []), ...this.emailRouteBuilder.getRuntimeSmtpsHostnameRoutes(preparedRoutes || []), ]; await this.assertGeneratedRuntimeCertificateOwnership( runtimeRoutes, 'Runtime route application', ); return runtimeRoutes; }, (storedRoute: IRoute) => this.emailRouteBuilder.hydrateStoredRouteForRuntime(storedRoute), (routes) => this.routePolicyAugmenter.applyInboundProxyProtocolPolicies(routes), ); // Certificate requirements may only be created for domains whose // ownership can be proven. Wired before initialize() so the startup // audit and every subsequent mutation see the same source. this.routeConfigManager.setDomainOwnershipSource({ listOwnershipZones: async () => { if (!this.dnsManager) { throw new Error('DnsManager is unavailable, domain ownership cannot be verified'); } return await this.dnsManager.listOwnershipZones(); }, getAuthorityZones: () => this.dnsAuthorityManager?.getEffectiveZoneNames() || [], }); this.apiTokenManager = new ApiTokenManager(); await this.apiTokenManager.initialize(); this.gatewayClientManager = new GatewayClientManager(); await this.gatewayClientManager.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(); // Seed default profiles/targets if DB is empty and seeding is enabled const seeder = new DbSeeder(this.referenceResolver); await seeder.seedIfEmpty( this.options.dbConfig?.seedOnEmpty, this.options.dbConfig?.seedData, ); }) .withStop(async () => { this.routeConfigManager = undefined; this.apiTokenManager = undefined; this.gatewayClientManager = undefined; this.referenceResolver = undefined; this.targetProfileManager = undefined; }) .withRetry({ maxRetries: 2, baseDelayMs: 1000 }), ); } // Gateway route DNS reconciliation starts only after durable route config // and DNS providers are available. if (this.options.dbConfig?.enabled !== false) { this.serviceManager.addService( new plugins.taskbuffer.Service('GatewayRouteDnsReconciler') .optional() .dependsOn('ConfigManagers', 'DnsManager') .withStart(async () => { this.gatewayRouteDnsReconciler = new GatewayRouteDnsReconciler(this); await this.gatewayRouteDnsReconciler.start(); }) .withStop(async () => { if (this.gatewayRouteDnsReconciler) { await this.gatewayRouteDnsReconciler.stop(); this.gatewayRouteDnsReconciler = undefined; } }) .withRetry({ maxRetries: 1, baseDelayMs: 500 }), ); } // Email Server: optional, depends on SmartProxy and durable DB persistence. if (this.options.dbConfig?.enabled !== false) { const emailServiceDeps = ['SmartProxy', 'MetricsManager']; emailServiceDeps.push('EmailDomainManager'); // Accounts must be loaded before setupUnifiedEmailHandling composes auth. emailServiceDeps.push('SmtpAccountManager'); // Explicit dependency: the outbound mode must come from the DB-backed settings; // without it, deliveries defer via the coordinator's strict mode resolution // instead of going direct. emailServiceDeps.push('EmailSettingsManager'); this.serviceManager.addService( new plugins.taskbuffer.Service('EmailServer') .optional() .dependsOn(...emailServiceDeps) .withStart(async () => { await this.queueEmailLifecycleTask(async () => { if (!this.options.emailConfig) { logger.log('info', 'EmailServer: no email settings configured, skipping startup'); return; } await this.setupUnifiedEmailHandling(); }); }) .withStop(async () => { await this.queueEmailLifecycleTask(async () => { await this.stopUnifiedEmailComponents(); }); }) .withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }), ); } else if (this.options.emailConfig) { logger.log('warn', 'EmailServer: dbConfig.enabled=false, skipping SMTP startup because accepted email requires durable DB persistence'); } // DNS Server: optional, depends on SmartProxy and on the authority set. // // Registration used to require a non-empty bootstrap `dnsScopes`, which // meant a router whose authority lived entirely in the database never // started its DNS server at all. The gate is now the nameserver identity — // without `dnsNsDomains` there is nothing a delegation could name, so no // zone could ever be verified — plus the database, because that is where // authority comes from. `DnsManager` is listed as a dependency so it starts // first — it owns `DnsAuthorityManager` — but ordering is all `dependsOn` // provides, so `DnsServerRuntime.setup()` checks the authority state itself // rather than trusting the graph. if (this.options.dnsNsDomains?.length && this.options.dbConfig?.enabled !== false) { const dnsServerDeps = ['SmartProxy', 'DnsManager', 'ConfigManagers', 'EmailServer']; this.serviceManager.addService( new plugins.taskbuffer.Service('DnsServer') .optional() .dependsOn(...dnsServerDeps) .withStart(async () => { await this.dnsServerRuntime.setup(); }) .withStop(async () => { await this.dnsServerRuntime.stop(); }) .withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }), ); } else if (this.options.dnsNsDomains?.length) { logger.log( 'warn', 'DnsServer: dbConfig.enabled=false, skipping the embedded DNS server because DNS authority is ' + 'delegation-verified state held in the database — without it there is no zone dcrouter may answer for', ); } // RADIUS Server: optional, no dependency on SmartProxy if (this.options.radiusConfig) { this.serviceManager.addService( new plugins.taskbuffer.Service('RadiusServer') .optional() .withStart(async () => { await this.setupRadiusServer(); }) .withStop(async () => { if (this.radiusServer) { await this.radiusServer.stop(); this.radiusServer = undefined; } }) .withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }), ); } // Remote Ingress: optional, depends on SmartProxy and DB-backed settings. // The service starts as a no-op when the DB setting is disabled, so the UI // can still manage edge registrations and hub settings. if (this.options.dbConfig?.enabled !== false) { this.serviceManager.addService( new plugins.taskbuffer.Service('RemoteIngress') .optional() .dependsOn('SmartProxy', 'RemoteIngressManager') .withStart(async () => { await this.remoteIngressHubLifecycle.setup(); }) .withStop(async () => { await this.remoteIngressHubLifecycle.stop(); }) .withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }), ); // Mail DNS reconciliation starts last: it needs persistence, provider // state, the SMTP DKIM creator, and live tunnel topology. Earlier events // only mark the reconciler dirty and are consumed here. const mailDnsSyncDependencies = [ 'DnsManager', 'EmailDomainManager', 'EmailServer', 'RemoteIngress', ]; if (this.options.dnsNsDomains?.length) { mailDnsSyncDependencies.push('DnsServer'); } this.serviceManager.addService( new plugins.taskbuffer.Service('MailDnsSync') .optional() .dependsOn(...mailDnsSyncDependencies) .withStart(async () => { await this.mailDnsSync.start(); }) .withStop(async () => { await this.mailDnsSync.stop(); }) .withRetry({ maxRetries: 1, baseDelayMs: 1000 }), ); } // VPN Server: optional, depends on SmartProxy if (this.options.vpnConfig?.enabled) { const vpnServiceDeps = ['SmartProxy']; if (this.options.dbConfig?.enabled !== false) { vpnServiceDeps.push('ConfigManagers'); } this.serviceManager.addService( new plugins.taskbuffer.Service('VpnServer') .optional() .dependsOn(...vpnServiceDeps) .withStart(async () => { await this.setupVpnServer(); }) .withStop(async () => { if (this.vpnManager) { await this.vpnManager.stop(); this.vpnManager = undefined; } }) .withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }), ); } // Wire up aggregated events for logging this.serviceSubjectSubscription = this.serviceManager.serviceSubject.subscribe((event) => { const level = event.type === 'failed' ? 'error' : event.type === 'retrying' ? 'warn' : 'info'; logger.log(level as any, `Service '${event.serviceName}': ${event.type}`, { state: event.state, ...(event.error ? { error: event.error } : {}), ...(event.attempt ? { attempt: event.attempt } : {}), }); }); } public isRemoteIngressHubEnabled(): boolean { return this.remoteIngressManager?.getHubSettings().enabled ?? this.options.remoteIngressConfig?.enabled ?? false; } private getRemoteIngressHubSettingsMigrationSeed(): TRemoteIngressHubSettingsUpdate { const remoteIngressConfig = this.options.remoteIngressConfig; const seed: TRemoteIngressHubSettingsUpdate = {}; if (remoteIngressConfig?.enabled !== undefined) { seed.enabled = remoteIngressConfig.enabled; } if (remoteIngressConfig?.tunnelPort !== undefined) { seed.tunnelPort = remoteIngressConfig.tunnelPort; } if (remoteIngressConfig?.hubDomain !== undefined) { seed.hubDomain = remoteIngressConfig.hubDomain; } if (remoteIngressConfig?.performance !== undefined) { seed.performance = remoteIngressConfig.performance; } return seed; } private getEmailSettingsMigrationSeed(): IEmailServerSettingsSeed { const seed: IEmailServerSettingsSeed = {}; if (this.options.emailConfig) { seed.enabled = true; seed.emailConfig = JSON.parse(JSON.stringify(this.options.emailConfig)); } if (this.options.emailOutboundMode) { seed.outboundMode = this.options.emailOutboundMode; } if (this.options.emailPortConfig) { seed.emailPortConfig = JSON.parse(JSON.stringify(this.options.emailPortConfig)); } return seed; } public async start() { await this.checkSystemLimits(); logger.log('info', 'Starting DcRouter Services'); await this.serviceManager.start(); this.logStartupSummary(); } /** * Detect OS-level resource limits and warn if they are too low for production use. * This is detection only — no attempts to raise limits. */ private async checkSystemLimits(): Promise { try { const fs = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode()); const limitsContent = await fs.file('/proc/self/limits').encoding('utf8').read() as string; const nofileLine = limitsContent.split('\n').find((line: string) => line.startsWith('Max open files')); if (nofileLine) { const parts = nofileLine.split(/\s{2,}/); const softLimit = parseInt(parts[1], 10); const hardLimit = parseInt(parts[2], 10); if (softLimit < 65536) { logger.log('warn', `File descriptor soft limit is ${softLimit} (hard: ${hardLimit}). ` + `For production use, set --ulimit nofile=65536:65536 on the container runtime.`); } else { logger.log('info', `File descriptor limits: soft=${softLimit}, hard=${hardLimit}`); } } } catch { // Non-Linux or /proc not available — silently skip } } /** * Log comprehensive startup summary */ private logStartupSummary(): void { logger.log('info', 'DcRouter Started Successfully'); // Metrics summary if (this.metricsManager) { logger.log('info', 'Metrics Service: SmartMetrics active, SmartProxy stats active, real-time tracking enabled'); } // SmartProxy summary if (this.smartProxy) { const routeCount = this.options.smartProxyConfig?.routes?.length || 0; const acmeEnabled = this.options.smartProxyConfig?.acme?.enabled || false; const acmeMode = acmeEnabled ? `email=${this.options.smartProxyConfig!.acme!.email || 'not set'}, mode=${this.options.smartProxyConfig!.acme!.useProduction ? 'production' : 'staging'}` : 'disabled'; logger.log('info', `SmartProxy Service: ${routeCount} routes, ACME: ${acmeMode}`); } // Email service summary if (this.emailServer && this.options.emailConfig) { const ports = this.options.emailConfig.ports || []; const domainCount = this.options.emailConfig.domains?.length || 0; const domainNames = this.options.emailConfig.domains?.map(d => `${d.domain} (${d.dnsMode || 'default'})`).join(', ') || 'none'; logger.log('info', `Email Service: ports=[${ports.join(', ')}], hostname=${this.options.emailConfig.hostname || 'localhost'}, domains=${domainCount} [${domainNames}], DKIM initialized`); } // DNS service summary if (this.dnsServer && this.options.dnsNsDomains) { const authorityZones = this.dnsAuthorityManager?.getEffectiveZoneNames() || []; logger.log('info', `DNS Service: nameservers=[${this.options.dnsNsDomains.join(', ')}], authoritative for ${authorityZones.length} delegation-verified zone(s) [${authorityZones.join(', ') || 'none'}], UDP:53, DoH enabled`); } // RADIUS service summary if (this.radiusServer && this.options.radiusConfig) { const vlanStats = this.radiusServer.getVlanManager().getStats(); logger.log('info', `RADIUS Service: auth=${this.options.radiusConfig.authPort || 1812}, acct=${this.options.radiusConfig.acctPort || 1813}, clients=${this.options.radiusConfig.clients?.length || 0}, VLANs=${vlanStats.totalMappings}, accounting=${this.options.radiusConfig.accounting?.enabled ? 'enabled' : 'disabled'}`); } // VPN summary if (this.vpnManager && this.options.vpnConfig?.enabled) { const subnet = this.vpnManager.getSubnet(); const wgPort = this.options.vpnConfig.wgListenPort ?? 51820; const clientCount = this.vpnManager.listClients().length; logger.log('info', `VPN Service: subnet=${subnet}, wg=:${wgPort}, clients=${clientCount}`); } // Remote Ingress summary const remoteIngressHubSettings = this.remoteIngressManager?.getHubSettings(); if (this.tunnelManager && remoteIngressHubSettings?.enabled) { const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0; const connectedCount = this.tunnelManager.getConnectedCount(); logger.log('info', `Remote Ingress: tunnel port=${remoteIngressHubSettings.tunnelPort}, edges=${edgeCount} registered/${connectedCount} connected`); } // Web Push provider summary logger.log( 'info', `Web Push Provider: ${this.webPushManager.isReady ? 'ready' : this.webPushManager.isEnabled ? 'unavailable' : 'disabled'}`, ); // Database summary if (this.dcRouterDb) { logger.log('info', `Database: ${this.dcRouterDb.isEmbedded() ? 'embedded' : 'external'}, db=${this.dcRouterDb.getDbName()}, cleaner=${this.cacheCleaner?.isActive() ? 'active' : 'inactive'} (${(this.options.dbConfig?.cleanupIntervalHours || 1)}h interval)`); } // Service status summary from ServiceManager const health = this.serviceManager.getHealth(); const statuses = health.services; const running = statuses.filter(s => s.state === 'running').length; const failed = statuses.filter(s => s.state === 'failed').length; const retrying = statuses.filter(s => s.state === 'starting' || s.state === 'degraded').length; if (failed > 0) { const failedNames = statuses.filter(s => s.state === 'failed').map(s => `${s.name}: ${s.lastError || 'unknown'}`); logger.log('warn', `DcRouter started in degraded mode — ${running} running, ${failed} failed: ${failedNames.join('; ')}`); } else if (retrying > 0) { logger.log('info', `DcRouter started — ${running} running, ${retrying} still initializing`); } else { logger.log('info', `All ${running} services are running`); } // Compare claimed authority against real delegation, in the background. // // Nothing used to check these two representations against each other, which // is how one zone sat declared in bootstrap config while four others were // live and delegated to our nameservers. Detached and advisory: it makes // network calls, so it must never delay or fail startup, and it never mutates // the authority set — a resolver blip at boot must not revoke authority. void this.dnsAuthorityManager?.logDelegationDrift(); } /** * Set up the unified database (smartdata + LocalSmartDb or external MongoDB) */ private async setupDcRouterDb(): Promise { logger.log('info', 'Setting up DcRouterDb...'); this.databaseMigrationsReady = false; const dbConfig = this.options.dbConfig || {}; // Initialize DcRouterDb singleton this.dcRouterDb = DcRouterDb.getInstance({ mongoDbUrl: dbConfig.mongoDbUrl, storagePath: dbConfig.storagePath || this.resolvedPaths.defaultTsmDbPath, dbName: dbConfig.dbName || 'dcrouter', debug: false, }); try { await this.dcRouterDb.start(); if (this.options.emailBlobStorage && !this.smartMtaBlobStorageManager) { this.smartMtaBlobStorageManager = await SmartMtaBlobStorageManager.create( this.options.emailBlobStorage, ); } // Run any pending data migrations before anything else reads from the DB. // This must complete before ConfigManagers loads profiles. const migration = await createMigrationRunner(this.dcRouterDb.getDb(), commitinfo.version, { remoteIngressHubSettings: this.getRemoteIngressHubSettingsMigrationSeed(), emailServerSettings: this.getEmailSettingsMigrationSeed(), legacySmartMtaStoragePath: plugins.path.join( this.resolvedPaths.dataDir, 'smartmta-storage', ), legacyRawMessageBlobStorage: this.smartMtaBlobStorageManager, }); const migrationResult = await migration.run(); if (migrationResult.stepsApplied.length > 0) { logger.log('info', `smartmigration: ${migrationResult.currentVersionBefore ?? 'fresh'} → ${migrationResult.currentVersionAfter} ` + `(${migrationResult.stepsApplied.length} step(s) applied in ${migrationResult.totalDurationMs}ms)`, ); } else if (migrationResult.wasFreshInstall) { logger.log('info', `smartmigration: fresh install stamped to ${migrationResult.currentVersionAfter}`); } this.storageManager = new SmartMtaStorageManager(this.dcRouterDb.getDb()); // Start the cache cleaner for TTL-based document cleanup const cleanupIntervalMs = (dbConfig.cleanupIntervalHours || 1) * 60 * 60 * 1000; this.cacheCleaner = new CacheCleaner(this.dcRouterDb, { intervalMs: cleanupIntervalMs, verbose: false, beforeDeleteCachedEmail: async (cachedEmail) => { await this.acceptedEmailSpool.deleteRawMessage(cachedEmail); }, }); this.cacheCleaner.start(); this.databaseMigrationsReady = true; logger.log('info', `DcRouterDb ready (${this.dcRouterDb.isEmbedded() ? 'embedded' : 'external'})`); } catch (error) { this.databaseMigrationsReady = false; if (this.cacheCleaner) { this.cacheCleaner.stop(); this.cacheCleaner = undefined; } const failedDb = this.dcRouterDb; this.dcRouterDb = undefined; try { await failedDb?.stop(); } catch (stopError: unknown) { logger.log( 'warn', `Failed to stop DcRouterDb after startup migration failure: ${(stopError as Error).message}`, ); } finally { DcRouterDb.resetInstance(); } throw error; } } /** * Set up SmartProxy with direct configuration and automatic email routes */ private async setupSmartProxy(): Promise { logger.log('info', 'Setting up SmartProxy...'); // Clean up any existing SmartProxy instance (e.g. from a retry) if (this.smartProxy) { const existingSmartProxy = this.smartProxy; try { existingSmartProxy.removeAllListeners(); await existingSmartProxy.stop(); if (this.smartProxy === existingSmartProxy) { this.smartProxy = undefined; } } finally { await this.smartAcmeLifecycle.stop(); } } // Assemble serializable seed routes from constructor config — these will be seeded into DB // by RouteConfigManager.initialize() when the ConfigManagers service starts. this.seedConfigRoutes = (this.options.smartProxyConfig?.routes || []) as plugins.smartproxy.IRouteConfig[]; logger.log('info', `Found ${this.seedConfigRoutes.length} routes in config`); this.seedEmailRoutes = []; if (this.options.emailConfig && this.options.dbConfig?.enabled !== false) { this.seedEmailRoutes = this.emailRouteBuilder.generateEmailRoutes(this.options.emailConfig); logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) }); } else if (this.options.emailConfig) { logger.log('warn', 'Email routes skipped because dbConfig.enabled=false and SMTP acceptance requires durable DB persistence'); } this.seedDnsRoutes = []; this.runtimeDnsRoutes = []; if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) { 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) }); } const runtimeEmailRoutes = this.emailRouteBuilder.getRuntimeEmailRoutes( this.seedEmailRoutes as IDcRouterRouteConfig[], ); await this.assertGeneratedRuntimeCertificateOwnership( runtimeEmailRoutes, 'SmartProxy bootstrap', ); // Combined routes for SmartProxy bootstrap (before DB routes are loaded) let routes: plugins.smartproxy.IRouteConfig[] = [ ...this.seedConfigRoutes, ...runtimeEmailRoutes, ...this.runtimeDnsRoutes, ]; // Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager. // If no config exists or it's disabled, SmartProxy's own ACME is turned off // and dcrouter's SmartAcme / certProvisionFunction are not wired. const dbAcme = this.acmeConfigManager?.getConfig(); const acmeConfig: plugins.smartproxy.IAcmeOptions | undefined = dbAcme && dbAcme.enabled ? { accountEmail: dbAcme.accountEmail, enabled: true, useProduction: dbAcme.useProduction, autoRenew: dbAcme.autoRenew, renewThresholdDays: dbAcme.renewThresholdDays, } : undefined; if (acmeConfig) { logger.log( 'info', `ACME config: accountEmail=${acmeConfig.accountEmail}, useProduction=${acmeConfig.useProduction}, autoRenew=${acmeConfig.autoRenew}`, ); } else { logger.log('info', 'ACME config: disabled or not yet configured in DB'); } // Configure DNS-01 challenge if any DnsProviderDoc exists in the DB AND // ACME is enabled. The DnsManager dispatches each challenge through the // unified createRecord()/deleteRecord() path — works for both dcrouter-hosted // zones and provider-managed zones. Only domains under management get certs. let challengeHandlers: any[] = []; if ( acmeConfig && this.dnsManager && (await this.dnsManager.hasAnyManagedDomain()) ) { logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (managed domains)'); const convenientDnsProvider = this.dnsManager.buildAcmeConvenientDnsProvider(); const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(convenientDnsProvider); challengeHandlers.push(dns01Handler); } // HTTP/3 augmentation (enabled by default unless explicitly disabled) if (this.options.http3?.enabled !== false) { const http3Config: IHttp3Config = { enabled: true, ...this.options.http3 }; routes = augmentRoutesWithHttp3(routes, http3Config); logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration'); } routes = this.routePolicyAugmenter.applyInboundProxyProtocolPolicies(routes); const compiledSecurityPolicy = await this.securityPolicyManager?.compileSmartProxyPolicy(); const mergedSecurityPolicy = this.routePolicyAugmenter.mergeSecurityPolicies( (this.options.smartProxyConfig as any)?.securityPolicy, compiledSecurityPolicy, ); // If we have routes or need a basic SmartProxy instance, create it if (routes.length > 0 || this.options.smartProxyConfig) { logger.log('info', 'Setting up SmartProxy with combined configuration'); // Track cert entries loaded from cert store so we can populate certificateStatusMap after start const loadedCertEntries: Array<{domain: string; publicKey: string; validUntil?: number; validFrom?: number}> = []; // Create SmartProxy configuration with sensible gateway defaults. // User's smartProxyConfig overrides these defaults via spread. const smartProxyConfig: plugins.smartproxy.ISmartProxyOptions = { // --- dcrouter gateway defaults --- maxConnectionsPerIP: 100, connectionRateLimitPerMinute: 600, socketTimeout: 120_000, inactivityTimeout: 120_000, keepAlive: true, noDelay: true, gracefulShutdownTimeout: 30_000, // --- user overrides --- ...this.options.smartProxyConfig, // --- deep-merge defaults.security so user can override maxConnections --- defaults: { ...this.options.smartProxyConfig?.defaults, security: { maxConnections: 50_000, ...this.options.smartProxyConfig?.defaults?.security, }, }, // --- always set by dcrouter (after spread) --- routes, acme: acmeConfig, ...(mergedSecurityPolicy ? { securityPolicy: mergedSecurityPolicy } as any : {}), certStore: { loadAll: async () => { const docs = await ProxyCertDoc.findAll(); const certs: Array<{ domain: string; publicKey: string; privateKey: string; ca?: string }> = []; for (const doc of docs) { certs.push({ domain: doc.domain, publicKey: doc.publicKey, privateKey: doc.privateKey, ca: doc.ca }); loadedCertEntries.push({ domain: doc.domain, publicKey: doc.publicKey, validUntil: doc.validUntil, validFrom: doc.validFrom }); } return certs; }, save: async (domain: string, publicKey: string, privateKey: string, ca?: string) => { let validUntil: number | undefined; let validFrom: number | undefined; try { const x509 = new plugins.crypto.X509Certificate(publicKey); validUntil = new Date(x509.validTo).getTime(); validFrom = new Date(x509.validFrom).getTime(); } catch { /* PEM parsing failed */ } let doc = await ProxyCertDoc.findByDomain(domain); if ( doc && doc.publicKey === publicKey && doc.privateKey === privateKey && doc.ca === (ca || '') ) { // Unchanged cert — skip the write. Re-saving identical PEMs on // every provisioning sweep is what ballooned the DB WAL/oplog. return; } if (!doc) { doc = new ProxyCertDoc(); doc.domain = domain; } doc.publicKey = publicKey; doc.privateKey = privateKey; doc.ca = ca || ''; doc.validUntil = validUntil || 0; doc.validFrom = validFrom || 0; await doc.save(); }, remove: async (domain: string) => { const doc = await ProxyCertDoc.findByDomain(domain); if (doc) { await doc.delete(); } }, }, }; // Initialize cert provision scheduler this.certProvisionScheduler = new CertProvisionScheduler(); // If we have DNS challenge handlers, create SmartAcme instance and wire certProvisionFunction. // SmartAcme starts in the background because ACME account setup can be slow or rate-limited, // and must not block dcrouter's global startup timeout. if (this.smartAcme) { await this.smartAcmeLifecycle.stop(); } if (challengeHandlers.length > 0) { // Safe non-null: challengeHandlers.length > 0 implies both dnsManager // and acmeConfig exist (enforced above). this.smartAcme = new plugins.smartacme.SmartAcme({ accountEmail: dbAcme!.accountEmail, certManager: new StorageBackedCertManager(), environment: dbAcme!.useProduction ? 'production' : 'integration', challengeHandlers: challengeHandlers, challengePriority: ['dns-01'], }); if (this.smartAcmeLifecycle.serviceStarted) { this.smartAcmeLifecycle.startInBackground(); } const scheduler = this.certProvisionScheduler; smartProxyConfig.certProvisionFallbackToAcme = false; smartProxyConfig.certProvisionFunction = async (domain, eventComms) => { // If SmartAcme is not yet ready (still starting or retrying), fall back to HTTP-01 if (!this.smartAcmeLifecycle.ready) { eventComms.warn(`SmartAcme not yet initialized, falling back to http-01 for ${domain}`); return 'http01'; } // Pre-flight: a hostname whose ownership we cannot prove has no zone // able to hold the DNS-01 challenge record, so the order can only ever // fail. Refuse before touching the per-domain retry budget — this is // the cause that silently consumed 31–45 attempts per domain and never // surfaced as anything but generic ACME noise. const ownership = await this.dnsManager!.resolveDomainOwnership(domain); if (!ownership.verified) { const permanentError = new DomainOwnershipError( ownership, 'Certificate provisioning', 'cert-provision-function', { data: { domain } }, ); eventComms.error(permanentError.message); throw permanentError; } // Check backoff before attempting provision if (await scheduler.isInBackoff(domain)) { const info = await scheduler.getBackoffInfo(domain); const msg = `Domain ${domain} is in backoff (${info?.failures} failures), retry after ${info?.retryAfter}`; eventComms.warn(msg); throw new Error(msg); } try { // smartacme v9 handles concurrency, per-domain dedup, and rate limiting internally eventComms.log(`Attempting DNS-01 via SmartAcme for ${domain}`); eventComms.setSource('smartacme-dns-01'); const isWildcardDomain = domain.startsWith('*.'); const cert = await this.smartAcme!.getCertificateForDomain(domain, { includeWildcard: !isWildcardDomain, }); // Parse real X509 expiry from PEM (defense-in-depth over SmartAcme's estimate) let realValidUntil = cert.validUntil; if (cert.publicKey) { try { const x509 = new plugins.crypto.X509Certificate(cert.publicKey); realValidUntil = new Date(x509.validTo).getTime(); } catch { /* fallback to SmartAcme's value */ } } if (realValidUntil) { eventComms.setExpiryDate(new Date(realValidUntil)); } const result = { id: cert.id, domainName: cert.domainName, created: cert.created, validUntil: realValidUntil, privateKey: cert.privateKey, publicKey: cert.publicKey, csr: cert.csr, }; // Success — clear any backoff await scheduler.clearBackoff(domain); return result; } catch (err: unknown) { const classification = classifyAcmeFailure(err); if (classification.permanent) { // A configuration cause cannot be retried away. Consuming the // per-domain budget here is what hid four broken domains behind // ordinary backoff warnings for weeks, so it is left untouched and // the failure is raised as an attributable terminal error instead. const permanentError = new AcmePermanentFailureError( classification, `DNS-01 for ${domain}`, 'cert-provision-function', { data: { domain } }, ); eventComms.error(permanentError.message); throw permanentError; } const message = `DNS-01 failed for ${domain}: ${(err as Error).message}`; await scheduler.recordFailure(domain, message); eventComms.warn(message); throw new Error(message); } }; } // RemoteIngress and VPN forward through localhost with PROXY protocol. // SmartProxy only uses this as a trust list; routes still opt in per listener. if (this.isRemoteIngressHubEnabled() || this.options.vpnConfig?.enabled) { const trustedProxyIPs = new Set(smartProxyConfig.trustedProxyIPs || []); trustedProxyIPs.add('127.0.0.1'); smartProxyConfig.trustedProxyIPs = [...trustedProxyIPs]; } // Create SmartProxy instance logger.log('info', `Creating SmartProxy instance: routes=${smartProxyConfig.routes?.length}, acme=${smartProxyConfig.acme?.enabled}, certProvisionFunction=${!!smartProxyConfig.certProvisionFunction}`); const smartProxy = new plugins.smartproxy.SmartProxy(smartProxyConfig); smartProxy.registerChallengeProvider( 'smartchallenge', new plugins.smartchallenge.SmartChallengeProvider({ challengeTypes: [new plugins.smartchallenge.WaitChallengeType()], }), ); this.smartProxy = smartProxy; // Set up event listeners smartProxy.on('error', (err) => { logger.log('error', `SmartProxy error: ${err.message}`, { stack: err.stack }); }); // Always listen for certificate events — emitted by both ACME and certProvisionFunction paths // Events are keyed by domain for domain-centric certificate tracking smartProxy.on('certificate-issued', (event: plugins.smartproxy.ICertificateIssuedEvent) => { logger.log('info', `Certificate issued for ${event.domain} via ${event.source}, expires ${event.expiryDate}`); const routeNames = this.findRouteNamesForDomain(event.domain); this.certificateStatusMap.set(event.domain, { status: 'valid', routeNames, expiryDate: event.expiryDate, issuedAt: new Date().toISOString(), source: event.source, }); // Renewals arrive here too. The SMTP listener holds its PEM material as // listener-level Rust configuration, so a renewed mail-hostname // certificate must be pushed or STARTTLS keeps serving the stale one. void this.reapplyEmailTlsMaterial(event.domain); }); // Note: smartproxy v27.5.0 emits only 'certificate-issued' and 'certificate-failed'. // Renewals come through 'certificate-issued' (with optional isRenewal? in the payload). // The vestigial 'certificate-renewed' event from common-types.ts is never emitted. smartProxy.on('certificate-failed', (event: plugins.smartproxy.ICertificateFailedEvent) => { logger.log('error', `Certificate failed for ${event.domain} (${event.source}): ${event.error}`); const routeNames = this.findRouteNamesForDomain(event.domain); this.certificateStatusMap.set(event.domain, { status: 'failed', routeNames, error: event.error, source: event.source, }); }); // Start SmartProxy logger.log('info', 'Starting SmartProxy...'); try { await smartProxy.start(); } catch (err) { smartProxy.removeAllListeners(); if (this.smartProxy === smartProxy) { this.smartProxy = undefined; } await this.smartAcmeLifecycle.stop(); if (this.certProvisionScheduler) { this.certProvisionScheduler.clear(); this.certProvisionScheduler = undefined; } await smartProxy.stop().catch((stopErr) => { logger.log('warn', `Failed to clean up SmartProxy after startup failure: ${(stopErr as Error).message}`); }); throw err; } logger.log('info', 'SmartProxy started successfully'); // Populate certificateStatusMap for certs loaded from store at startup for (const entry of loadedCertEntries) { if (!this.certificateStatusMap.has(entry.domain)) { const routeNames = this.findRouteNamesForDomain(entry.domain); let expiryDate: string | undefined; let issuedAt: string | undefined; // Use validUntil/validFrom from stored proxy-certs data if available if (entry.validUntil) { expiryDate = new Date(entry.validUntil).toISOString(); } if (entry.validFrom) { issuedAt = new Date(entry.validFrom).toISOString(); } // Try SmartAcme AcmeCertDoc metadata as secondary source if (!expiryDate) { try { const cleanDomain = entry.domain.replace(/^\*\.?/, ''); const domParts = cleanDomain.split('.'); const baseDomain = domParts.length > 2 ? domParts.slice(-2).join('.') : cleanDomain; const certDoc = await AcmeCertDoc.findByDomain(baseDomain) || (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null); if (certDoc?.validUntil) { expiryDate = new Date(certDoc.validUntil).toISOString(); } if (certDoc?.created && !issuedAt) { issuedAt = new Date(certDoc.created).toISOString(); } } catch { /* no metadata available */ } } // Fallback: parse X509 from PEM to get expiry if (!expiryDate && entry.publicKey) { try { const x509 = new plugins.crypto.X509Certificate(entry.publicKey); expiryDate = new Date(x509.validTo).toISOString(); if (!issuedAt) { issuedAt = new Date(x509.validFrom).toISOString(); } } catch { /* PEM parsing failed */ } } this.certificateStatusMap.set(entry.domain, { status: 'valid', routeNames, expiryDate, issuedAt, source: 'cert-store', }); } } if (loadedCertEntries.length > 0) { logger.log('info', `Populated certificate status for ${loadedCertEntries.length} store-loaded domain(s)`); } logger.log('info', `SmartProxy started with ${routes.length} routes`); } } private async assertGeneratedRuntimeCertificateOwnership( routes: plugins.smartproxy.IRouteConfig[], operation: string, ): Promise { const hostnames = new Set(); for (const route of routes) { for (const hostname of collectAutoCertificateHostnames(route as IDcRouterRouteConfig)) { hostnames.add(hostname); } } if (hostnames.size === 0) { return; } if (!this.dnsManager) { throw new Error( `${operation} refused: DnsManager is unavailable, so generated certificate requirements cannot be verified`, ); } for (const hostname of hostnames) { const ownership = await this.dnsManager.resolveDomainOwnership(hostname); if (!ownership.verified) { throw new DomainOwnershipError( ownership, operation, 'dcrouter-generated-route', { data: { domain: hostname } }, ); } } } private handleRemoteIngressEdgesChanged(reason: string): void { this.mailDnsSync.requestEdgeEligibilityCheck(reason); this.routeConfigManager?.applyRoutes().catch((err: unknown) => { logger.log( 'error', `Failed to re-apply routes after Remote Ingress edge change (${reason}): ${(err as Error).message}`, ); }); } public async applySecurityPolicy(): Promise { if (!this.securityPolicyManager) { return; } const compiledSmartProxyPolicy = await this.securityPolicyManager.compileSmartProxyPolicy(); const mergedSecurityPolicy = this.routePolicyAugmenter.mergeSecurityPolicies( (this.options.smartProxyConfig as any)?.securityPolicy, compiledSmartProxyPolicy, ); if (this.smartProxy && mergedSecurityPolicy) { const smartProxyWithPolicyApi = this.smartProxy as any; if (typeof smartProxyWithPolicyApi.updateSecurityPolicy === 'function') { await smartProxyWithPolicyApi.updateSecurityPolicy(mergedSecurityPolicy); } } const firewallConfig = await this.securityPolicyManager.compileRemoteIngressFirewall(); await this.remoteIngressHubLifecycle.applyFirewallConfig(firewallConfig); } /** * Generate SmartProxy routes for DNS configuration */ private generateDnsRoutes(options?: { includeSocketHandler?: boolean }): plugins.smartproxy.IRouteConfig[] { if (!this.options.dnsNsDomains || this.options.dnsNsDomains.length === 0) { return []; } const includeSocketHandler = options?.includeSocketHandler !== false; const primaryNameserver = this.options.dnsNsDomains[0]; return [{ name: 'dns-over-https-dns-query', match: { ports: [443], domains: [primaryNameserver], }, action: { type: 'socket-handler', tls: { mode: 'terminate', certificate: 'auto' }, ...(includeSocketHandler ? { socketHandler: this.dnsServerRuntime.createSocketHandler() } : {}), }, }]; } /** * Check if a domain matches a pattern (including wildcard support) * @param domain The domain to check * @param pattern The pattern to match against (e.g., "*.example.com") * @returns Whether the domain matches the pattern */ private isDomainMatch(domain: string, pattern: string): boolean { domain = domain.toLowerCase(); pattern = pattern.toLowerCase(); if (domain === pattern) return true; // Routing-glob: *example.com matches example.com, sub.example.com, *.example.com if (pattern.startsWith('*') && !pattern.startsWith('*.')) { const baseDomain = pattern.slice(1); // *nevermind.cloud → nevermind.cloud if (domain === baseDomain || domain === `*.${baseDomain}`) return true; if (domain.endsWith(baseDomain) && domain.length > baseDomain.length) return true; } // Standard wildcard: *.example.com matches sub.example.com and example.com if (pattern.startsWith('*.')) { const suffix = pattern.slice(2); if (domain === suffix) return true; return domain.endsWith(suffix) && domain.length > suffix.length; } return false; } /** * Find ALL route names that match a given domain */ public findRouteNamesForDomain(domain: string): string[] { if (!this.smartProxy) return []; const names: string[] = []; for (const route of this.smartProxy.routeManager.getRoutes()) { if (!route.match.domains || !route.name) continue; const routeDomains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; for (const pattern of routeDomains) { if (this.isDomainMatch(domain, pattern)) { names.push(route.name); break; // This route already matched, no need to check other patterns } } } return names; } private async stopOpsServer(): Promise { if (this.opsServerStopped) return; this.opsServerStopped = true; try { await this.opsServer?.stop(); } catch (error) { this.opsServerStopped = false; throw error; } } public async stop() { logger.log('info', 'Stopping DcRouter services...'); // Unsubscribe from service events before stopping services if (this.serviceSubjectSubscription) { this.serviceSubjectSubscription.unsubscribe(); this.serviceSubjectSubscription = undefined; } // Stop accepting authenticated requests before MetricsManager performs its // final durable authentication-event and email-traffic drain. await this.stopOpsServer(); // Mail DNS reconciliation may legitimately outlive ServiceManager's // non-cancelling per-service timeout. Drain it before dependency teardown. await this.mailDnsSync.stop(); // DNS process termination must be confirmed before ServiceManager's // non-cancelling timeout can return control to the CLI's process exit. await this.dnsServerRuntime.stop(); // ServiceManager handles reverse-dependency-ordered shutdown await this.serviceManager.stop(); // Clear backoff cache in cert scheduler if (this.certProvisionScheduler) { this.certProvisionScheduler.clear(); this.certProvisionScheduler = undefined; } this.certificateStatusMap.clear(); // Reset security singletons to allow GC SecurityLogger.resetInstance(); ContentScanner.resetInstance(); IPReputationChecker.resetInstance(); logger.log('info', 'All DcRouter services stopped'); } /** * Update SmartProxy configuration * @param config New SmartProxy configuration */ public async updateSmartProxyConfig(config: plugins.smartproxy.ISmartProxyOptions): Promise { // Stop existing SmartProxy if running if (this.smartProxy) { this.smartProxy.removeAllListeners(); await this.smartProxy.stop(); this.smartProxy = undefined; } // Update configuration this.options.coreTrafficConfig = config; this.options.smartProxyConfig = config; // Start new SmartProxy with updated configuration (rebuilds seed routes) await this.setupSmartProxy(); // Re-seed and re-apply all routes after SmartProxy restart if (this.routeConfigManager) { await this.routeConfigManager.initialize( this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], ); } logger.log('info', 'SmartProxy configuration updated'); } /** * Set up unified email handling with pattern-based routing * This implements the consolidated emailConfig approach */ private async setupUnifiedEmailHandling(): Promise { if (!this.options.emailConfig) { throw new Error('Email configuration is required for unified email handling'); } if (!this.dcRouterDb?.isReady()) { throw new Error('DcRouterDb is required for email acceptance'); } // Apply port mapping if behind SmartProxy if (!this.databaseMigrationsReady) { throw new Error('DcRouterDb migrations must complete before email acceptance'); } const portMapping = this.options.emailPortConfig?.portMapping || { 25: 10025, // SMTP 587: 10587, // Submission 465: 10465 // SMTPS }; // Create config with mapped ports const baseEmailConfig = this.options.emailConfig; const configuredMessageDataHook = baseEmailConfig.hooks?.onMessageData; const mappedEmailPorts = baseEmailConfig.ports.map(port => portMapping[port] || port + 10000); const configuredSecurePort = baseEmailConfig.smtp?.securePort; const mappedSecurePort = configuredSecurePort === undefined ? undefined : mappedEmailPorts.includes(configuredSecurePort) && !baseEmailConfig.ports.includes(configuredSecurePort) ? configuredSecurePort : portMapping[configuredSecurePort] || configuredSecurePort + 10000; const queueBehavior = { ...baseEmailConfig.queue } as Record; delete queueBehavior.storageMode; delete queueBehavior.storageManager; delete queueBehavior.storageType; delete queueBehavior.persistentPath; if (!this.smartMtaBlobStorageManager) { throw new Error( 'emailBlobStorage is required for durable SmartMTA queue and raw RFC822 persistence', ); } const queueOptions: IUnifiedEmailServerOptions['queue'] = { ...queueBehavior, storageMode: 'managed', storageManager: this.smartMtaBlobStorageManager, }; const emailTls = await this.resolveEmailTlsMaterial(); const tlsTerminatedPorts = this.resolveEdgeTerminatedEmailPorts(portMapping); this.logEmailAuthTransportPosture(emailConfigHasAuth(baseEmailConfig), emailTls, mappedEmailPorts, tlsTerminatedPorts, mappedSecurePort); let emailConfig: IUnifiedEmailServerOptions = await this.smtpAccountManager.composeEmailConfig({ ...this.options.emailConfig, ports: mappedEmailPorts, domains: applyDefaultInboundPolicy(this.options.emailConfig.domains), dkimKeyProvisioning: 'caller-managed', persistRoutes: this.options.emailConfig.persistRoutes ?? false, queue: queueOptions, ...(emailTls ? { tls: { ...baseEmailConfig.tls, certPem: emailTls.certPem, keyPem: emailTls.keyPem } } : {}), outbound: { ...baseEmailConfig.outbound, connectionProxyProvider: (context) => this.mailEgressCoordinator.provideConnectionProxy(context), }, smtp: { ...baseEmailConfig.smtp, ...(mappedSecurePort !== undefined ? { securePort: mappedSecurePort } : {}), ...(tlsTerminatedPorts.length > 0 ? { tlsTerminatedPorts } : {}), recipientValidation: true, proxyProtocol: { ...baseEmailConfig.smtp?.proxyProtocol, required: true, trustedIps: ['127.0.0.1', '::1'], }, }, hooks: { ...baseEmailConfig.hooks, onMessageData: async (context) => { const managedSenderDecision = await this.workAppMailManager.enforceManagedSmtpSender(context); if (managedSenderDecision) { return managedSenderDecision; } const configuredDecision = configuredMessageDataHook ? await configuredMessageDataHook(context) : undefined; if (configuredDecision && !configuredDecision.accepted) { return configuredDecision; } const dcrouterDecision = await this.acceptedEmailSpool.acceptMessage( context, configuredDecision ? configuredDecision.continueProcessing === true : true, ); return { ...dcrouterDecision, smtpCode: configuredDecision?.smtpCode ?? dcrouterDecision.smtpCode, smtpMessage: configuredDecision?.smtpMessage ?? dcrouterDecision.smtpMessage, }; }, onAcceptEnvelope: async (context) => { const emailServer = this.emailServer; if (!emailServer) { throw new Error('Email server is not available for durable envelope acceptance'); } await this.acceptedEmailSpool.acceptEnvelope(context, emailServer); }, }, }); // Create unified email server let emailServer = new UnifiedEmailServer(this, emailConfig); this.emailServer = emailServer; this.clearEmailEventSubscriptions(); try { const repairResult = await this.emailDomainManager?.repairManagedDkimBeforeEmailStart(); if (repairResult && ( repairResult.failedDomainIds.length > 0 || repairResult.failedConfiguredDomains.length > 0 )) { emailConfig = await this.smtpAccountManager.composeEmailConfig({ ...emailConfig, domains: applyDefaultInboundPolicy(this.options.emailConfig?.domains), }); // DkimManager captures its DomainRegistry at construction. Rebuild the // unstarted server so per-domain repair failures are absent from // caller-managed DKIM startup validation. emailServer = new UnifiedEmailServer(this, emailConfig); this.emailServer = emailServer; } } catch (error) { if (this.emailServer === emailServer) { this.emailServer = undefined; } throw error; } // Set up error handling this.addEmailEventSubscription(emailServer, 'error', (err: Error) => { logger.log('error', `UnifiedEmailServer error: ${err.message}`); }); // Mailer bridge lifecycle — these must land in the ops log store: a // failed or restarting bridge means mail processing is degraded, and // silent bridge deaths were undiagnosable in production. this.addEmailEventSubscription(emailServer, 'bridgeFailed', () => { logger.log('error', 'Mailer bridge entered failed state — mail processing degraded, background recovery continues'); }); this.addEmailEventSubscription(emailServer, 'bridgeRestarting', () => { logger.log('warn', 'Mailer bridge crashed — restart in progress'); }); this.addEmailEventSubscription(emailServer, 'bridgeRecovered', () => { logger.log('info', 'Mailer bridge recovered after restart'); }); // Live AUTH toggles restart only the SMTP listener; a failed restart // leaves SMTP down while the rest of the server runs. Surface it loudly — // isSmtpListenerActive() stays false until recovery. this.addEmailEventSubscription(emailServer, 'smtpListenerRestartFailed', (err: unknown) => { logger.log('error', `SMTP listener restart failed after an auth update — inbound SMTP is down until recovery: ${(err as Error)?.message || err}`); }); // Start the server try { await emailServer.start(); // Restore failed managed domains as inbound-only after DKIM validation. // Outbound routes and SMTP users stay absent through readiness gating. await this.emailDomainManager?.syncManagedDomainsToRuntime(); await this.workAppMailManager.applyStoredIdentitiesToRuntime(); } catch (error: unknown) { this.clearEmailEventSubscriptions(); try { await emailServer.stop(); } catch (stopError: unknown) { logger.log('warn', `Error cleaning up failed UnifiedEmailServer start: ${(stopError as Error).message}`); } if (this.emailServer === emailServer) { this.emailServer = undefined; } throw error; } try { this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemEnqueued', async (item: TSmartMtaQueueItemLike) => { await this.acceptedEmailSpool.trackQueueUpdate(item, 'queued', 'Unable to update accepted email after queue enqueue'); }); this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDelivered', async (item: TSmartMtaQueueItemLike) => { await this.acceptedEmailSpool.trackQueueUpdate(item, 'delivered', 'Unable to mark accepted email delivered'); }); this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDeferred', async (item: TSmartMtaQueueItemLike) => { await this.acceptedEmailSpool.trackQueueUpdate(item, 'deferred', 'Unable to defer accepted email'); }); this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemFailed', async (item: TSmartMtaQueueItemLike) => { await this.acceptedEmailSpool.trackQueueUpdate(item, 'failed', 'Unable to mark accepted email failed'); }); this.addEmailEventSubscription( emailServer.deliverySystem, 'smtpTransactionCompleted', async (transaction: import('./db/documents/classes.cached.email.js').ICachedEmailSmtpTransaction) => { await this.acceptedEmailSpool.trackSmtpTransaction(transaction, emailServer); }, ); this.addEmailEventSubscription( emailServer.deliverySystem, 'smtpTransactionUpdated', async (transaction: import('./db/documents/classes.cached.email.js').ICachedEmailSmtpTransaction) => { await this.acceptedEmailSpool.trackSmtpTransaction(transaction, emailServer); }, ); // Wire delivery events to MetricsManager and logger using smartmta's public queue APIs. if (this.metricsManager) { const getEnvelope = (item: { processingResult?: any; lastError?: string }) => { const emailLike = item?.processingResult; const from = emailLike?.from || emailLike?.email?.from || ''; const recipients = Array.isArray(emailLike?.to) ? emailLike.to : Array.isArray(emailLike?.email?.to) ? emailLike.email.to : []; return { from, recipients: recipients.filter(Boolean), }; }; const updateQueueSize = () => { this.metricsManager!.updateQueueSize(emailServer.getQueueStats().queueSize); }; this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemEnqueued', (item: any) => { const envelope = getEnvelope(item); updateQueueSize(); logger.log('info', `Email queued: ${envelope.from} → ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' }); }); this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDelivered', (item: any) => { const envelope = getEnvelope(item); this.metricsManager!.trackEmailSent(envelope.recipients[0]); updateQueueSize(); logger.log('info', `Email delivered to ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' }); }); this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemFailed', (item: any) => { const envelope = getEnvelope(item); this.metricsManager!.trackEmailFailed(envelope.recipients[0], item?.lastError); updateQueueSize(); logger.log('warn', `Email delivery failed to ${envelope.recipients.join(', ') || 'unknown'}: ${item?.lastError || 'unknown error'}`, { zone: 'email' }); }); this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDeferred', () => { updateQueueSize(); }); this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemRemoved', () => { updateQueueSize(); }); this.addEmailEventSubscription(emailServer, 'bounceProcessed', () => { this.metricsManager!.trackEmailBounced(); logger.log('warn', 'Email bounce processed', { zone: 'email' }); }); updateQueueSize(); } await this.acceptedEmailSpool.recoverSmtpTransactionHistory(emailServer); await this.acceptedEmailSpool.recoverQueuedEmails(); this.acceptedEmailSpool.start(); } catch (error: unknown) { this.acceptedEmailSpool.beginStop(); try { await emailServer.stop(); } catch (stopError: unknown) { logger.log('warn', `Error cleaning up failed UnifiedEmailServer setup: ${(stopError as Error).message}`); } await this.acceptedEmailSpool.stop(); await this.acceptedEmailSpool.drainQueueUpdates(); this.clearEmailEventSubscriptions(); if (this.emailServer === emailServer) { this.emailServer = undefined; } throw error; } logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`); this.mailEgressCoordinator.logEgressPreflight(); this.mailDnsSync.requestSync('email server start'); } /** * Resolve the PEM material for the SMTP listener. * * SmartMTA consumes `tls.certPem`/`tls.keyPem`; a path-shaped `tls` block is * ignored, which leaves the Rust listener with no TLS material at all — no * STARTTLS on the plain submission ports. Resolution order matches the * RemoteIngress tunnel precedent: explicit paths, then the ACME cert store. * * Explicitly configured paths that cannot be read FAIL STARTUP. Silently * continuing without TLS is what produced a cleartext submission port in * production, so a broken explicit configuration must be impossible to miss. */ private async resolveEmailTlsMaterial(): Promise<{ certPem: string; keyPem: string; source: string } | undefined> { const tlsConfig = this.options.emailConfig?.tls as (IUnifiedEmailServerOptions['tls'] & { certPath?: string; keyPath?: string; }) | undefined; if (tlsConfig?.certPem && tlsConfig?.keyPem) { return { certPem: tlsConfig.certPem, keyPem: tlsConfig.keyPem, source: 'inline PEM' }; } if (tlsConfig?.certPath || tlsConfig?.keyPath) { if (!tlsConfig.certPath || !tlsConfig.keyPath) { throw new Error( 'emailConfig.tls requires both certPath and keyPath when either is configured', ); } let certPem: string; let keyPem: string; try { certPem = await plugins.fs.promises.readFile(tlsConfig.certPath, 'utf8'); keyPem = await plugins.fs.promises.readFile(tlsConfig.keyPath, 'utf8'); } catch (error: unknown) { throw new Error( `Unable to read the configured SMTP TLS material (certPath=${tlsConfig.certPath}, keyPath=${tlsConfig.keyPath}): ${(error as Error).message}`, ); } if (!certPem.trim() || !keyPem.trim()) { throw new Error( `The configured SMTP TLS material is empty (certPath=${tlsConfig.certPath}, keyPath=${tlsConfig.keyPath})`, ); } logger.log('info', `SMTP TLS material loaded from configured paths (${tlsConfig.certPath})`); return { certPem, keyPem, source: `configured paths (${tlsConfig.certPath})` }; } const mailHostname = this.options.emailConfig?.hostname; if (mailHostname) { try { const stored = await ProxyCertDoc.findByDomain(mailHostname); if (stored?.publicKey && stored?.privateKey) { logger.log('info', `SMTP TLS material loaded from the stored ACME certificate for ${mailHostname}`); return { certPem: stored.publicKey, keyPem: stored.privateKey, source: `stored ACME certificate for ${mailHostname}` }; } } catch (error: unknown) { logger.log('warn', `Unable to read the stored certificate for the mail hostname ${mailHostname}: ${(error as Error).message}`); } } return undefined; } /** * Push renewed TLS material to the running SMTP listener. * * Only reacts to the mail hostname, and only when explicit paths are NOT * configured — an operator-managed file pair is not superseded by an ACME * renewal for the same name. SmartMTA restarts just the Rust listener when * the PEM material actually changes. */ private async reapplyEmailTlsMaterial(domainArg: string): Promise { const emailServer = this.emailServer; const mailHostname = this.options.emailConfig?.hostname; if (!emailServer || !mailHostname) return; if (domainArg.toLowerCase() !== mailHostname.toLowerCase()) return; const tlsConfig = this.options.emailConfig?.tls; if (tlsConfig?.certPath || tlsConfig?.keyPath) return; try { const emailTls = await this.resolveEmailTlsMaterial(); if (!emailTls) return; emailServer.updateOptions({ tls: { ...this.options.emailConfig?.tls, certPem: emailTls.certPem, keyPem: emailTls.keyPem }, }); if (this.options.emailConfig) { this.options.emailConfig.tls = { ...this.options.emailConfig.tls, certPem: emailTls.certPem, keyPem: emailTls.keyPem, }; } logger.log('info', `Pushed renewed SMTP TLS material for ${mailHostname} to the email listener`); } catch (error: unknown) { logger.log('error', `Unable to apply renewed SMTP TLS material for ${mailHostname}: ${(error as Error).message}`); } } /** * Internal SMTP ports whose public leg is TLS-terminated by CoreTraffic. * * Those backend legs arrive as plaintext even though the client's channel was * encrypted, so without declaring them the AUTH-requires-encryption gate would * refuse authentication on the only encrypted submission port. Derived from * the generated route set rather than hardcoding 465, so it stays true when * emailPortConfig changes. */ private resolveEdgeTerminatedEmailPorts(portMapping: Record): number[] { if (!this.options.emailConfig) return []; const terminatedPorts: number[] = []; for (const route of this.emailRouteBuilder.generateEmailRoutes(this.options.emailConfig)) { if ((route.action as { tls?: { mode?: string } }).tls?.mode !== 'terminate') continue; const publicPorts = Array.isArray(route.match?.ports) ? route.match.ports : []; for (const publicPort of publicPorts) { if (typeof publicPort !== 'number') continue; const internalPort = portMapping[publicPort] || publicPort + 10000; // The implicit-TLS securePort is terminated by smartmta itself. if (internalPort === this.options.emailConfig.smtp?.securePort) continue; if (!terminatedPorts.includes(internalPort)) { terminatedPorts.push(internalPort); } } } return terminatedPorts; } /** * State the transport posture of every SMTP listener port at startup. * * AUTH is only offered on an encrypted transport, so an operator has to be * able to see at a glance which submission ports can actually authenticate — * missing certificate material silently disabling AUTH on 587 would otherwise * look like a client problem. */ private logEmailAuthTransportPosture( authConfigured: boolean, emailTls: { source: string } | undefined, mappedPorts: number[], tlsTerminatedPorts: number[], mappedSecurePort: number | undefined, ): void { const authCapable: number[] = []; const cleartext: number[] = []; for (const port of mappedPorts) { if (port === mappedSecurePort || tlsTerminatedPorts.includes(port) || emailTls) { authCapable.push(port); } else { cleartext.push(port); } } logger.log( 'info', `SMTP transport posture: TLS material=${emailTls ? emailTls.source : 'NONE'}, edge-terminated ports=[${tlsTerminatedPorts.join(', ') || 'none'}], implicit-TLS port=${mappedSecurePort ?? 'none'}, AUTH-capable ports=[${authCapable.join(', ') || 'none'}]`, ); if (authConfigured && !emailTls && cleartext.length > 0) { logger.log( 'error', `SMTP AUTH is configured but ports [${cleartext.join(', ')}] have no TLS material and no upstream TLS terminator — AUTH is refused there because credentials must never cross a cleartext channel. Configure emailConfig.tls.certPath/keyPath, or provision an ACME certificate for ${this.options.emailConfig?.hostname || 'the mail hostname'}.`, ); } } /** * Readiness of the RemoteIngress outbound mail egress path (mail-tagged edges). */ public getOutboundEgressStatus(): IEmailOutboundEgressStatus { return this.mailEgressCoordinator.getOutboundEgressStatus(); } /** * Update the unified email configuration * @param config New email configuration */ public async updateEmailConfig(config: IUnifiedEmailServerOptions): Promise { await this.queueEmailLifecycleTask(async () => { // Stop existing email components await this.stopUnifiedEmailComponents(); // Update configuration this.options.emailConfig = config; this.emailDomainManager?.setBaseEmailDomains(config.domains as IEmailDomainConfig[] | undefined); await this.emailDomainManager?.syncManagedDomainsToRuntime(); // Start email handling with new configuration await this.setupUnifiedEmailHandling(); logger.log('info', 'Unified email configuration updated'); }); } public async updateEmailServerSettings( settings: TEmailServerSettingsUpdate, updatedBy = 'system', ): Promise { return await this.queueEmailLifecycleTask(async () => { if (!this.emailSettingsManager) { throw new Error('EmailSettingsManager is not initialized'); } const updatedSettings = await this.emailSettingsManager.updateSettings(settings, updatedBy); this.emailDomainManager?.setBaseEmailDomains(this.options.emailConfig?.domains as IEmailDomainConfig[] | undefined); await this.emailDomainManager?.syncManagedDomainsToRuntime(); this.seedEmailRoutes = this.options.emailConfig ? this.emailRouteBuilder.generateEmailRoutes(this.options.emailConfig) : []; if (this.routeConfigManager) { 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[], ); } if (this.options.emailConfig) { if (this.emailServer) { await this.stopUnifiedEmailComponents(); } await this.setupUnifiedEmailHandling(); } else if (this.emailServer) { await this.stopUnifiedEmailComponents(); } return updatedSettings; }); } /** * Stop all unified email components */ private async stopUnifiedEmailComponents(): Promise { try { // Stop the unified email server which contains all components if (this.emailServer) { const emailServer = this.emailServer; this.emailServer = undefined; this.acceptedEmailSpool.beginStop(); try { await emailServer.stop(); } finally { await this.acceptedEmailSpool.stop(); await this.acceptedEmailSpool.drainQueueUpdates(); this.clearEmailEventSubscriptions(); } logger.log('info', 'Unified email server stopped'); } logger.log('info', 'All unified email components stopped'); } catch (error: unknown) { logger.log('error', `Error stopping unified email components: ${(error as Error).message}`); throw error; } } /** * Update domain rules for email routing * @param rules New domain rules to apply */ public async updateEmailRoutes(routes: IEmailRoute[]): Promise { // Validate that email config exists if (!this.options.emailConfig) { throw new Error('Email configuration is required before updating routes'); } // Update the configuration this.options.emailConfig.routes = routes; // Update the unified email server if it exists if (this.emailServer) { this.emailServer.updateEmailRoutes(routes); } logger.log('info', `Email routes updated with ${routes.length} routes`); } /** * Get statistics from all components */ public getStats(): any { const stats: any = { emailServer: this.emailServer?.getStats() }; return stats; } /** * Register DNS records with the DNS server * @param records Array of DNS records to register */ private addEmailEventSubscription( emitter: { on(eventName: string, listener: (...args: any[]) => void): void; off(eventName: string, listener: (...args: any[]) => void): void; }, eventName: string, listener: (...args: any[]) => void, ): void { emitter.on(eventName, listener); this.emailEventSubscriptions.push({ emitter, eventName, listener }); } private clearEmailEventSubscriptions(): void { for (const subscription of this.emailEventSubscriptions) { subscription.emitter.off(subscription.eventName, subscription.listener); } this.emailEventSubscriptions = []; } /** * Set up Remote Ingress hub for edge tunnel connections */ private queueSmartProxyLifecycleTask(task: () => Promise): Promise { const run = this.smartProxyLifecycleChain.then(task); this.smartProxyLifecycleChain = run.then(() => undefined, () => undefined); return run; } private queueEmailLifecycleTask(task: () => Promise): Promise { const run = this.emailLifecycleChain.then(task); this.emailLifecycleChain = run.then(() => undefined, () => undefined); return run; } /** Serialized edge mutation on the RemoteIngress hub (delegates to the hub lifecycle). */ public async mutateRemoteIngressEdges( mutation: (manager: RemoteIngressManager) => Promise, syncAllowedEdges = true, ): Promise { return await this.remoteIngressHubLifecycle.mutateEdges(mutation, syncAllowedEdges); } public async updateRemoteIngressHubSettings( updates: TRemoteIngressHubSettingsUpdate, updatedBy: string, ): Promise { return await this.remoteIngressHubLifecycle.updateHubSettings(updates, updatedBy); } /** * Re-derive every piece of runtime state that depends on the effective DNS * authority set, after a zone gained or lost delegation-verified authority. * * Deliberately reuses the existing machinery rather than paralleling it: * `reconcileAuthoritativeZones()` drives the same `runtimeRegistrations` * registry that domain deletion uses, and the overlay and route-warning * refreshes are the same calls the domain mutation paths make. Throws on the * first failure so `DnsAuthorityManager` can roll the change back rather than * leave the router half-converted. */ public async reconcileDnsAuthority(reasonArg: string): Promise { if (!this.dnsManager) { throw new Error('DnsManager is unavailable, DNS authority cannot be reconciled'); } logger.log('info', `Reconciling DNS authority: ${reasonArg}`, { zone: 'dns' }); // 0. The running DNS server's own zone set. This decides the *response // kind* — a name outside every configured zone is REFUSED even when a // handler answers other qtypes for it — so it has to move before the // handlers do, or a newly verified zone answers A while REFUSING AAAA // and SOA, which is precisely the production defect being fixed. this.dnsServerRuntime.syncAuthorityZones(reasonArg); // 1. Zone handlers: register what gained proof, tear down what lost it. const zoneResult = await this.dnsManager.reconcileAuthoritativeZones(); // 2. Keep the persisted authoritative flag honest with the new verdicts. const flagsChanged = await this.dnsManager.syncAuthoritativeFlags(); // 3. Certificate requirements: re-evaluate which routes are now provable. await this.routeConfigManager?.refreshDomainOwnershipWarnings(); // 4. The ownership-gated private-route overlay. await this.resyncPrivateRouteDnsOverlay(reasonArg); logger.log( 'info', `DNS authority reconciled (${reasonArg}): +${zoneResult.registered.length} / -${zoneResult.unregistered.length} apex NS zone(s), ` + `${flagsChanged} domain authoritative flag(s) corrected`, { zone: 'dns' }, ); } /** * Re-derive the private-route DNS overlay from the currently applied route set. * * The overlay is derived from routes but gated on domain ownership, so a domain * mutation can invalidate it without any route changing. Deleting a DomainDoc * is the case that matters: without this the overlay keeps answering `aa` for a * hostname whose zone we no longer manage until some unrelated route apply * happens. Named here so the mutation site can trigger its own invalidation * instead of forcing a full route re-apply. */ public async resyncPrivateRouteDnsOverlay(reasonArg: string): Promise { const appliedRoutes = this.smartProxy?.routeManager.getRoutes(); if (!appliedRoutes) { return; } try { await this.dnsServerRuntime.syncPrivateRouteOverrides( appliedRoutes as IDcRouterRouteConfig[], ); } catch (err: unknown) { logger.log( 'error', `Failed to re-sync the DNS private-route overlay after ${reasonArg}: ${(err as Error).message}`, ); } } /** Restart SmartProxy after RemoteIngress hub settings changed listener wiring. Called by RemoteIngressHubLifecycle. */ public async restartSmartProxyForRemoteIngressSettings(): Promise { await this.queueSmartProxyLifecycleTask(async () => { const restartSmartProxy = async () => { try { if (this.smartProxy) { const existingSmartProxy = this.smartProxy; existingSmartProxy.removeAllListeners(); await existingSmartProxy.stop(); if (this.smartProxy === existingSmartProxy) { this.smartProxy = undefined; } } } finally { await this.smartAcmeLifecycle.stop(); } await this.setupSmartProxy(); }; if (this.routeConfigManager) { await this.routeConfigManager.runExclusiveRouteUpdate(restartSmartProxy); } else { await restartSmartProxy(); } if (!this.routeConfigManager) { return; } await this.routeConfigManager.initialize( this.seedConfigRoutes as IDcRouterRouteConfig[], this.seedEmailRoutes as IDcRouterRouteConfig[], this.seedDnsRoutes as IDcRouterRouteConfig[], ); }); } /** Bootstrap routes the RemoteIngress hub uses to derive edge ports before the DB route set is applied. */ public getRemoteIngressBootstrapRoutes(): plugins.smartproxy.IRouteConfig[] { return [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.runtimeDnsRoutes, ...this.getEdgeOnlyDerivationRoutes()]; } /** Edge-derivation-only routes (see buildEdgeOnlyDerivationRoutes) — never passed to SmartProxy. */ public getEdgeOnlyDerivationRoutes(): IDcRouterRouteConfig[] { return buildEdgeOnlyDerivationRoutes(this.options); } /** * Set up VPN server for VPN-based route access control. */ private async setupVpnServer(): Promise { if (!this.options.vpnConfig?.enabled) { return; } if (this.options.dbConfig?.enabled === false) { throw new Error('VPN requires dbConfig.enabled because clients, keys, routes, and target profiles are persisted in DcRouterDb'); } if (!this.routeConfigManager || !this.targetProfileManager) { throw new Error('VPN requires initialized route and target profile managers'); } logger.log('info', 'Setting up VPN server...'); this.vpnManager = new VpnManager({ subnet: this.options.vpnConfig.subnet, wgListenPort: this.options.vpnConfig.wgListenPort, dns: this.options.vpnConfig.dns, serverEndpoint: this.options.vpnConfig.serverEndpoint, initialClients: this.options.vpnConfig.clients, destinationPolicy: this.options.vpnConfig.destinationPolicy, forwardingMode: this.options.vpnConfig.forwardingMode, bridgeLanSubnet: this.options.vpnConfig.bridgeLanSubnet, bridgePhysicalInterface: this.options.vpnConfig.bridgePhysicalInterface, bridgeIpRangeStart: this.options.vpnConfig.bridgeIpRangeStart, bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd, onClientChanged: () => { // Re-apply routes so profile-based VPN client grants get updated // (serialized by RouteConfigManager's mutex — safe as fire-and-forget) this.routeConfigManager?.applyRoutes().catch((err) => { logger.log('warn', `Failed to re-apply routes after VPN client change: ${err?.message || err}`); }); }, onClientSourceIpsChanged: () => { // SmartProxy now receives the real source IP per connection via PROXY v2. // Source-IP changes are reflected in status/UI only; route config is static. }, getClientDirectTargets: (targetProfileIds: string[]) => { if (!this.targetProfileManager) return []; return this.targetProfileManager.getDirectTargetIps(targetProfileIds); }, getClientAllowedIPs: async (targetProfileIds: string[], _clientId?: string, _sourceIp?: string) => await this.vpnAccessResolver.getClientAllowedIPs(targetProfileIds), }); await this.vpnManager.start(); // Re-apply routes now that VPN clients are loaded — ensures vpnOnly routes // get correct profile-based VPN client grants. await this.routeConfigManager?.applyRoutes(); } // VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes() // via the getVpnAllowList callback — no longer a separate method here. /** * Set up RADIUS server for network authentication */ private async setupRadiusServer(): Promise { if (!this.options.radiusConfig) { return; } logger.log('info', 'Setting up RADIUS server...'); this.radiusServer = new RadiusServer(this.options.radiusConfig); await this.radiusServer.start(); logger.log('info', `RADIUS server started on ports ${this.options.radiusConfig.authPort || 1812} (auth) and ${this.options.radiusConfig.acctPort || 1813} (acct)`); } /** * Update RADIUS configuration at runtime */ public async updateRadiusConfig(config: IRadiusServerConfig): Promise { // Stop existing RADIUS server if running if (this.radiusServer) { await this.radiusServer.stop(); this.radiusServer = undefined; } // Update configuration this.options.radiusConfig = config; // Start with new configuration await this.setupRadiusServer(); logger.log('info', 'RADIUS configuration updated'); } /** * Update VPN configuration at runtime. */ public async updateVpnConfig(config: IDcRouterOptions['vpnConfig']): Promise { if (this.vpnManager) { await this.vpnManager.stop(); this.vpnManager = undefined; } this.options.vpnConfig = config; this.vpnAccessResolver.reset(); this.routeConfigManager?.setVpnClientAccessResolver(this.vpnAccessResolver.createRouteAllowResolver()); if (this.options.vpnConfig?.enabled) { await this.setupVpnServer(); } else { await this.routeConfigManager?.applyRoutes(); } logger.log('info', 'VPN configuration updated'); } } // Re-export email server types for convenience export type { IUnifiedEmailServerOptions }; // Re-export RADIUS types for convenience export type { IRadiusServerConfig }; export default DcRouter;