import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; import { buildEmailDnsRecords } from '../email/index.js'; import type { DcRouter } from '../classes.dcrouter.js'; import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js'; import { resolveDomainOwnership, type IDomainOwnershipZone, } from './domain-ownership.js'; type TDnsRecordSeed = { name: string; type: string; value: string; ttl?: number; useIngressProxy?: boolean }; /** The required DNSSEC keying zone when dcrouter currently claims no DNS zone. */ export const NO_AUTHORITY_SENTINEL_ZONE = 'no-authority.invalid'; /** * Sets up and feeds the embedded authoritative smartdns server: validates the * DNS configuration, generates authoritative/email/DKIM records, applies * proxy-IP replacement, registers record handlers, wires rate-limited query * logging/metrics, and provides the DoH socket handler for SmartProxy routes. */ export class DnsServerRuntime { // Adaptive query-log rate limiting state private logWindowSecond = 0; // epoch second of current window private logWindowCount = 0; // queries logged this second private batchCount = 0; private batchTimer: ReturnType | null = null; private privateRouteHostnames = new Set(); private privateRouteTargetIp?: string; /** Local snapshot of the zone set successfully applied to smartdns. */ private readonly authoritativeZones: string[] = []; private attachedDnsServer?: plugins.smartdns.dnsServerMod.DnsServer; private cleanupPendingDnsServer?: plugins.smartdns.dnsServerMod.DnsServer; private cleanupPendingDnsServerPromise?: Promise; private lifecycleTail: Promise = Promise.resolve(); constructor(private dcRouterRef: DcRouter) {} /** The authority set, from the database and nowhere else. */ private effectiveAuthorityZones(): string[] { return this.dcRouterRef.dnsAuthorityManager?.getEffectiveZoneNames() || []; } /** * Create the DNS server, start it on UDP, wire metrics/logging, and * register all generated records. */ public setup(): Promise { return this.enqueueLifecycle(() => this.setupInternal()); } private async setupInternal(): Promise { await this.retryPendingDnsServerCleanup(); if (this.dcRouterRef.dnsServer) { throw new Error('DNS server setup refused because another server instance is still owned'); } const options = this.dcRouterRef.options; if (!options.dnsNsDomains || options.dnsNsDomains.length === 0) { throw new Error('dnsNsDomains is required for DNS server setup'); } // Refuse to start when the authority set is *unknown*. // // This guard is load-bearing and cannot be delegated to the service graph. // `DnsServer.dependsOn('DnsManager')` only orders startup levels — taskbuffer // starts later levels even when an earlier optional service failed — so // without this check an unreadable authority document would leave // `dnsAuthorityManager` undefined, `effectiveAuthorityZones()` would return // `[]`, and the server would come up REFUSING every query. That is exactly // the collapse of "unknown" into "claim nothing" that the three-state model // exists to prevent, and it would be indistinguishable from a legitimately // empty database. const authorityManager = this.dcRouterRef.dnsAuthorityManager; if (!authorityManager || authorityManager.getState() === 'unavailable') { throw new Error( 'DNS authority is unknown (the authority manager is ' + `${authorityManager ? 'unavailable' : 'not initialized'}), so the DNS server will not start. ` + 'dcrouter serves DNS authority from the database alone; answering with an empty authority set would ' + 'assert "we are authoritative for nothing", which is a claim an unreadable database cannot back. ' + 'Restore database access and restart.', ); } // A *known*-empty set is different, and must not stop the server: DoH // routes, the private-route overlay and, above all, the ability to pick up // a zone the moment it is verified all depend on the server being up. // Fail closed on *authority*, never silently on *service*. const bootAuthorityZones = this.syncAuthorityZones('DNS server setup'); if (bootAuthorityZones.length === 0) { logger.log( 'error', 'DNS server is starting with an empty authority set: no zone has been delegation-verified, so every ' + 'query will be REFUSED until one is. This is what an unseeded database looks like — read the startup ' + 'delegation drift audit for the list of zones delegated to us that we are refusing to serve.', { zone: 'dns' }, ); } const primaryNameserver = options.dnsNsDomains[0]; logger.log('info', `Setting up DNS server with primary nameserver: ${primaryNameserver}`); // Get VM IP address for UDP binding const networkInterfaces = plugins.os.networkInterfaces() as Record< string, Array<{ internal: boolean; family: string; address: string }> | undefined >; let vmIpAddress = options.dnsBindInterface || '0.0.0.0'; // Default to all interfaces // Try to find the VM's internal IP address when no explicit bind address is configured. if (!options.dnsBindInterface) { interfaceLoop: for (const [_name, interfaces] of Object.entries(networkInterfaces)) { if (interfaces) { for (const iface of interfaces) { if (!iface.internal && iface.family === 'IPv4') { vmIpAddress = iface.address; break interfaceLoop; } } } } } this.privateRouteTargetIp = plugins.net.isIP(vmIpAddress) && vmIpAddress !== '0.0.0.0' && vmIpAddress !== '::' ? vmIpAddress : undefined; // Create DNS server instance with manual HTTPS mode const dnsServer = new plugins.smartdns.dnsServerMod.DnsServer({ udpPort: 53, udpBindInterface: vmIpAddress, httpsPort: 443, // Required but won't bind due to manual mode manualHttpsMode: true, // Enable manual HTTPS socket handling // The DNSSEC signing zone is baked into the Rust config at start and // cannot be changed without a restart, so it is taken from the boot-time // authority set — sorted, therefore stable across restarts rather than // dependent on database insertion order. dnssecZone: bootAuthorityZones[0] || NO_AUTHORITY_SENTINEL_ZONE, // SmartDNS copies this boot snapshot; later changes use its live setter. authoritativeZones: bootAuthorityZones, primaryNameserver: primaryNameserver, // Automatically generates correct SOA records // For now, use self-signed cert until we integrate with Let's Encrypt httpsKey: '', httpsCert: '' }); this.dcRouterRef.dnsServer = dnsServer; this.registerPrivateRouteHandler(dnsServer); try { // SmartDNS owns UDP and DNS-over-TCP; dcrouter keeps DNS-over-HTTPS // routed through SmartProxy manual HTTPS mode. await dnsServer.start(); logger.log('info', `DNS server started on UDP/TCP ${vmIpAddress}:53`); // Wire DNS query events to MetricsManager and logger with adaptive rate limiting if (this.dcRouterRef.metricsManager) { const flushDnsBatch = () => { if (this.batchCount > 0) { logger.log('info', `DNS: ${this.batchCount} queries processed (rate limited)`, { zone: 'dns' }); this.batchCount = 0; } this.batchTimer = null; }; dnsServer.on('query', (event: plugins.smartdns.dnsServerMod.IDnsQueryCompletedEvent) => { // Metrics tracking for (const question of event.questions) { this.dcRouterRef.metricsManager?.trackDnsQuery( question.type, question.name, false, event.responseTimeMs, event.answered, ); } // Adaptive logging: individual logs up to 2/sec, then batch const nowSec = Math.floor(Date.now() / 1000); if (nowSec !== this.logWindowSecond) { this.logWindowSecond = nowSec; this.logWindowCount = 0; } if (this.logWindowCount < 2) { this.logWindowCount++; const summary = event.questions.map(q => `${q.type} ${q.name}`).join(', '); logger.log('info', `DNS query: ${summary} (${event.responseTimeMs}ms, ${event.answered ? 'answered' : 'unanswered'})`, { zone: 'dns' }); } else { this.batchCount++; if (!this.batchTimer) { this.batchTimer = setTimeout(flushDnsBatch, 5000); } } }); } // Validate DNS configuration await this.validateConfiguration(); // Generate and register authoritative records const authoritativeRecords = await this.generateAuthoritativeRecords(); // Generate email DNS records const emailDnsRecords = await this.generateEmailDnsRecords(); // Load caller-managed DKIM records without generating or rotating keys. const dkimRecords = await this.loadDkimRecords(); // Combine all records: authoritative, email, DKIM, and user-defined const allRecords: TDnsRecordSeed[] = [...authoritativeRecords, ...emailDnsRecords, ...dkimRecords]; if (options.dnsRecords && options.dnsRecords.length > 0) { allRecords.push(...options.dnsRecords); } // Apply proxy IP replacement if configured await this.applyProxyIpReplacement(allRecords); // Register all DNS records if (allRecords.length > 0) { this.registerRecords(allRecords); logger.log('info', `Registered ${allRecords.length} DNS records (${authoritativeRecords.length} authoritative, ${emailDnsRecords.length} email, ${dkimRecords.length} DKIM, ${options.dnsRecords?.length || 0} user-defined)`); } // Hand the DnsServer to DnsManager so DB-backed local records on // dcrouter-hosted domains get registered too. await this.attachDnsServer(dnsServer); } catch (error) { this.detachDnsServer(dnsServer); dnsServer.removeAllListeners(); let stopError: Error | undefined; try { await dnsServer.stop(); } catch (errorDuringStop: unknown) { stopError = errorDuringStop instanceof Error ? errorDuringStop : new Error(String(errorDuringStop)); this.cleanupPendingDnsServer = dnsServer; void this.ensureDnsServerCleanup(dnsServer, true); logger.log('warn', `Failed to stop DNS server after setup failure: ${stopError.message}`); } if (!stopError && this.dcRouterRef.dnsServer === dnsServer) { this.dcRouterRef.dnsServer = undefined; } this.flushQueryLogBatch(); if (stopError) { throw new AggregateError( [error, stopError], 'DNS server setup failed and process cleanup remains pending', ); } throw error; } } private async retryPendingDnsServerCleanup(): Promise { const pendingServer = this.cleanupPendingDnsServer; if (!pendingServer) return; await this.ensureDnsServerCleanup(pendingServer); } /** * Keep retrying until SmartDNS confirms Rust process closure. Taskbuffer * swallows service-stop errors, so returning after a failed termination would * otherwise strand the child and its port bindings with no future retry. */ private ensureDnsServerCleanup( dnsServerArg: plugins.smartdns.dnsServerMod.DnsServer, delayFirstAttemptArg = false, ): Promise { if (this.cleanupPendingDnsServerPromise) { if (this.cleanupPendingDnsServer !== dnsServerArg) { throw new Error('Cannot clean up two DNS server instances concurrently'); } return this.cleanupPendingDnsServerPromise; } this.cleanupPendingDnsServer = dnsServerArg; let cleanupPromise: Promise; cleanupPromise = this.cleanupDnsServerUntilStopped(dnsServerArg, delayFirstAttemptArg) .finally(() => { if (this.cleanupPendingDnsServerPromise === cleanupPromise) { this.cleanupPendingDnsServerPromise = undefined; } }); this.cleanupPendingDnsServerPromise = cleanupPromise; return cleanupPromise; } private async cleanupDnsServerUntilStopped( dnsServerArg: plugins.smartdns.dnsServerMod.DnsServer, delayFirstAttemptArg: boolean, ): Promise { let failedAttempts = 0; if (delayFirstAttemptArg) { await this.waitForDnsServerCleanupRetry(1); } while (this.cleanupPendingDnsServer === dnsServerArg) { try { await dnsServerArg.stop(); if (this.dcRouterRef.dnsServer === dnsServerArg) { this.dcRouterRef.dnsServer = undefined; } if (this.cleanupPendingDnsServer === dnsServerArg) { this.cleanupPendingDnsServer = undefined; } return; } catch (error: unknown) { failedAttempts++; const message = error instanceof Error ? error.message : String(error); logger.log( 'warn', `DNS server process cleanup attempt ${failedAttempts} failed; retrying until closure is confirmed: ${message}`, ); await this.waitForDnsServerCleanupRetry(failedAttempts); } } } private async waitForDnsServerCleanupRetry(failedAttemptsArg: number): Promise { const delayMs = Math.min(250 * (2 ** Math.min(failedAttemptsArg - 1, 5)), 5000); await new Promise((resolve) => setTimeout(resolve, delayMs)); } private enqueueLifecycle(operationArg: () => Promise): Promise { const operation = this.lifecycleTail.then(operationArg, operationArg); this.lifecycleTail = operation.then(() => undefined, () => undefined); return operation; } private async attachDnsServer( dnsServer: plugins.smartdns.dnsServerMod.DnsServer, ): Promise { if (!this.dcRouterRef.dnsManager) return; await this.dcRouterRef.dnsManager.attachDnsServer(dnsServer); this.attachedDnsServer = dnsServer; this.dcRouterRef.mailDnsSync?.requestSync('DNS server attached'); } private detachDnsServer(dnsServerArg: plugins.smartdns.dnsServerMod.DnsServer): void { this.dcRouterRef.dnsManager?.detachDnsServer(dnsServerArg); if (this.attachedDnsServer === dnsServerArg) { this.attachedDnsServer = undefined; } } /** * Re-derive the running server's authoritative zone set from the database. * * This is the half of authority that registering handlers cannot express. * smartdns decides the *response kind* from `authoritativeZones` alone: a name * inside a configured zone gets an answer or an authoritative negative, and a * name outside every configured zone gets REFUSED — even when a handler is * registered and answers other qtypes for that exact name. That asymmetry is * the live production defect. `social.io` and `hard.global` are delegated to * us and have handlers, so `A` is answered with the `aa` bit, while `AAAA` and * `SOA` for the same name are REFUSED, and public recursives turn a REFUSED * arm of a dual-stack lookup into SERVFAIL. Verifying a zone therefore has to * update this set, not just register handlers, or the zone stays half-served. * * Idempotent, and safe to call on every authority reconcile. * * @returns the zone set now in effect. */ public syncAuthorityZones(reasonArg: string): string[] { const nextZones = this.effectiveAuthorityZones(); const changed = nextZones.length !== this.authoritativeZones.length || nextZones.some((zone, index) => zone !== this.authoritativeZones[index]); if (!changed) { return nextZones; } // Commit the local snapshot only after the live server accepts the update. this.dcRouterRef.dnsServer?.setAuthoritativeZones(nextZones); this.authoritativeZones.splice(0, this.authoritativeZones.length, ...nextZones); logger.log( 'info', `DNS authoritative zone set is now [${nextZones.join(', ') || 'empty'}] (${reasonArg})`, { zone: 'dns' }, ); return nextZones; } /** * Keep an internal-only A-record overlay for exact route hostnames whose * compiled source policy is private or whose only ingress is SmartVPN. * Public routes compile without clientIp restrictions and are never added. * * Every hostname must clear the domain-ownership gate first. The handler uses * SmartDNS' explicit non-authoritative mode, but the server still listens on a * public UDP/TCP 53. Ungated, a private-route definition could publicly hand * out an RFC1918 address for a domain whose real delegation belonged elsewhere. */ public async syncPrivateRouteOverrides(routesArg: IDcRouterRouteConfig[]): Promise { const candidateHostnames = new Set(); for (const route of routesArg) { if (route.action?.type !== 'forward') continue; const ingress = route.ingress; if (!ingress?.directHub && !ingress?.smartVpn) continue; const clientIp = route.match?.clientIp; const clientIpEntries = Array.isArray(clientIp) ? clientIp : []; const hasRestrictedSourceProfile = clientIpEntries .map((entryArg) => entryArg.trim()) .filter(Boolean) .some((entryArg) => entryArg !== '*'); const isSmartVpnOnly = ingress.smartVpn && !ingress.directHub; if (!hasRestrictedSourceProfile && !isSmartVpnOnly) continue; const domains = route.match?.domains; const domainEntries = Array.isArray(domains) ? domains : typeof domains === 'string' ? domains.split(',') : []; for (const domainArg of domainEntries) { const hostname = this.normalizePrivateRouteHostname(domainArg); if (hostname) candidateHostnames.add(hostname); } } const nextHostnames = await this.filterOwnedPrivateRouteHostnames(candidateHostnames); const changed = nextHostnames.size !== this.privateRouteHostnames.size || [...nextHostnames].some((hostnameArg) => !this.privateRouteHostnames.has(hostnameArg)); this.privateRouteHostnames = nextHostnames; if (changed) { logger.log( 'info', `DNS private-route overlay now serves ${nextHostnames.size} exact hostname(s)`, { zone: 'dns' }, ); } } /** * Drop overlay hostnames whose zone ownership cannot be proven, logging each * rejection at `error` with the reason. Fails closed: if the ownership inputs * cannot be read, no hostname is served rather than all of them. */ private async filterOwnedPrivateRouteHostnames( candidateHostnames: Set, ): Promise> { if (candidateHostnames.size === 0) { return new Set(); } // The delegation-verified authority set, so a zone verified at runtime // starts serving its overlay entries without a restart. const authorityZones = this.dcRouterRef.dnsManager?.getAuthorityZones() || this.effectiveAuthorityZones(); let zones: IDomainOwnershipZone[]; try { zones = (await this.dcRouterRef.dnsManager?.listOwnershipZones()) || []; } catch (error: unknown) { logger.log( 'error', `DNS private-route overlay: cannot read managed zones (${(error as Error).message}); ` + `refusing to serve ${candidateHostnames.size} private route hostname(s) rather than answering for unverified domains`, { zone: 'dns' }, ); return new Set(); } const ownedHostnames = new Set(); for (const hostname of candidateHostnames) { const ownership = resolveDomainOwnership({ fqdn: hostname, zones, authorityZones }); if (ownership.verified) { ownedHostnames.add(hostname); continue; } logger.log( 'error', `DNS private-route overlay: refusing to answer for '${hostname}' — ${ownership.detail}. ` + 'dcrouter answers authoritatively on a public port, so serving this would claim a domain we cannot prove we own.', { zone: 'dns', ownershipReason: ownership.reason }, ); } return ownedHostnames; } /** Register the split-horizon overlay without claiming DNS authority. */ private registerPrivateRouteHandler( dnsServerArg: plugins.smartdns.dnsServerMod.DnsServer, ): void { dnsServerArg.registerHandler('*', ['A'], (questionArg) => { const hostname = this.normalizePrivateRouteHostname(questionArg.name); if ( !hostname || questionArg.type.toUpperCase() !== 'A' || !this.privateRouteTargetIp || !this.privateRouteHostnames.has(hostname) ) { return null; } return { name: hostname, type: 'A', class: 'IN', ttl: 60, data: this.privateRouteTargetIp, }; }, { authority: 'non-authoritative', owner: 'private-route-overlay' }); } private normalizePrivateRouteHostname(hostnameArg: string): string | undefined { const hostname = hostnameArg.trim().toLowerCase().replace(/\.$/, ''); if (!hostname || hostname.includes('*') || hostname.includes(',')) return undefined; if (hostname.length > 253) return undefined; const labels = hostname.split('.'); if (labels.some((labelArg) => !labelArg || labelArg.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(labelArg))) return undefined; return hostname; } /** Create the DoH handler for cleartext sockets after SmartProxy terminates TLS. */ public createSocketHandler(): (socket: plugins.net.Socket) => Promise { return async (socket: plugins.net.Socket) => { if (!this.dcRouterRef.dnsServer) { logger.log('error', 'DNS socket handler called but DNS server not initialized'); socket.end(); return; } // Prevent uncaught exception from socket 'error' events socket.on('error', (err) => { logger.log('error', `DNS socket error: ${err.message}`); if (!socket.destroyed) { socket.destroy(); } }); logger.log('debug', 'DNS socket handler: passing socket to DnsServer'); try { this.dcRouterRef.dnsServer.handleHttpSocket(socket); } catch (error: unknown) { logger.log('error', `DNS socket handler error: ${(error as Error).message}`); if (!socket.destroyed) { socket.destroy(); } } }; } /** Flush the pending rate-limited query-log batch and reset logging state. */ public flushQueryLogBatch(): void { if (this.batchTimer) { clearTimeout(this.batchTimer); if (this.batchCount > 0) { logger.log('info', `DNS: ${this.batchCount} queries processed (final flush)`, { zone: 'dns' }); } this.batchTimer = null; this.batchCount = 0; this.logWindowSecond = 0; this.logWindowCount = 0; } } /** Detach handlers and retain termination ownership until process closure. */ public stop(): Promise { return this.enqueueLifecycle(() => this.stopInternal()); } private async stopInternal(): Promise { this.flushQueryLogBatch(); const ownedServers = new Set(); if (this.attachedDnsServer) ownedServers.add(this.attachedDnsServer); if (this.cleanupPendingDnsServer) ownedServers.add(this.cleanupPendingDnsServer); if (this.dcRouterRef.dnsServer) ownedServers.add(this.dcRouterRef.dnsServer); for (const dnsServer of ownedServers) { this.detachDnsServer(dnsServer); dnsServer.removeAllListeners(); await this.ensureDnsServerCleanup(dnsServer); } } private registerRecords(records: TDnsRecordSeed[]): void { const dnsServer = this.dcRouterRef.dnsServer; if (!dnsServer) return; // Register a separate handler for each record // This ensures multiple records of the same type (like NS records) are all served for (const record of records) { // Register handler for this specific record dnsServer.registerHandler(record.name, [record.type], (question) => { // Check if this handler matches the question if (question.name === record.name && question.type === record.type) { return { name: record.name, type: record.type, class: 'IN', ttl: record.ttl || 300, data: this.parseRecordData(record.type, record.value) }; } return null; }); } logger.log('info', `Registered ${records.length} DNS handlers (one per record)`); } private parseRecordData(type: string, value: string): any { switch (type) { case 'A': return value; // IP address as string case 'MX': const [preference, exchange] = value.split(' '); return { preference: parseInt(preference, 10), exchange }; case 'TXT': return value; case 'NS': return value; case 'SOA': // SOA format: primary-ns admin-email serial refresh retry expire minimum const parts = value.split(' '); return { mname: parts[0], rname: parts[1], serial: parseInt(parts[2]), refresh: parseInt(parts[3]), retry: parseInt(parts[4]), expire: parseInt(parts[5]), minimum: parseInt(parts[6]) }; default: return value; } } private async validateConfiguration(): Promise { const options = this.dcRouterRef.options; if (!options.dnsNsDomains) { return; } const authorityZones = this.effectiveAuthorityZones(); logger.log('info', 'Validating DNS configuration...'); const covered = (nameArg: string): boolean => authorityZones.some((zone) => nameArg === zone || nameArg.endsWith(`.${zone}`)); // Email domains served from the embedded DNS need the zone to be ours. if (options.emailConfig?.domains) { for (const domainConfig of options.emailConfig.domains) { if (domainConfig.dnsMode === 'internal-dns' && !covered(domainConfig.domain.toLowerCase())) { logger.log( 'warn', `Email domain '${domainConfig.domain}' uses internal-dns but its zone is not delegation-verified, ` + 'so dcrouter will REFUSE queries for it. Verify the zone (dns-authority:write) to serve it.', ); } } } // Validate caller-provided DNS records fall inside a zone we may answer for if (options.dnsRecords) { for (const record of options.dnsRecords) { const recordDomain = this.extractDomain(record.name).toLowerCase(); if (!covered(recordDomain)) { logger.log( 'warn', `DNS record for '${record.name}' is outside every delegation-verified zone ` + `[${authorityZones.join(', ') || 'none'}] and will be REFUSED`, ); } } } } private async generateEmailDnsRecords(): Promise { const options = this.dcRouterRef.options; const records: TDnsRecordSeed[] = []; if (!options.emailConfig?.domains) { return records; } const managedEmailDomains = await this.getManagedEmailDomainNames(); // Filter domains with internal-dns mode const internalDnsDomains = options.emailConfig.domains.filter( domain => domain.dnsMode === 'internal-dns' && !managedEmailDomains.has(domain.domain.toLowerCase()) ); for (const domainConfig of internalDnsDomains) { const domain = domainConfig.domain; const ttl = domainConfig.dns?.internal?.ttl || 3600; const requiredRecords = buildEmailDnsRecords({ domain, hostname: options.emailConfig.hostname, mxPriority: domainConfig.dns?.internal?.mxPriority, }).filter((record) => !record.name.includes('._domainkey.')); for (const record of requiredRecords) { records.push({ name: record.name, type: record.type, value: record.value, ttl, }); } } logger.log('info', `Generated ${records.length} email DNS records for ${internalDnsDomains.length} internal-dns domains`); return records; } private async loadDkimRecords(): Promise { const options = this.dcRouterRef.options; const records: TDnsRecordSeed[] = []; if (!options.emailConfig?.domains || !this.dcRouterRef.emailServer?.dkimCreator) { return records; } const managedEmailDomains = await this.getManagedEmailDomainNames(); for (const domainConfig of options.emailConfig.domains) { if ( domainConfig.dnsMode !== 'internal-dns' || managedEmailDomains.has(domainConfig.domain.toLowerCase()) ) { continue; } const selector = domainConfig.dkim?.selector || 'default'; try { const dkimRecord = await this.dcRouterRef.emailServer.dkimCreator.getDNSRecordForSelector( domainConfig.domain, selector, ); records.push({ name: dkimRecord.name, type: 'TXT', value: dkimRecord.value, ttl: domainConfig.dns?.internal?.ttl || 3600, }); } catch (error: unknown) { logger.log( 'error', `Caller-managed DKIM material is unavailable for ${selector}._domainkey.${domainConfig.domain}: ${(error as Error).message}`, ); } } return records; } private async getManagedEmailDomainNames(): Promise> { return new Set( (await this.dcRouterRef.emailDomainManager?.getAll() || []) .map((domainArg) => domainArg.domain.toLowerCase()), ); } /** * Nameserver A records (glue) only. * * Generated apex NS records used to be emitted here, one static set per * bootstrap `dnsScopes` entry, registered once at setup and never revisited. * Under database-sourced authority that is wrong twice over: a zone verified * after startup would never get them, and a zone whose authority was revoked * would keep them until an unrelated restart. `DnsManager` owns generated apex * NS for every authoritative zone now — it already did for every zone that was * not in `dnsScopes` — and reconciles them in-process in both directions. * A verified zone with no `DomainDoc` behind it therefore serves no apex NS, * which the `verified-but-unhosted` drift finding reports at `error`. */ private async generateAuthoritativeRecords(): Promise { const options = this.dcRouterRef.options; const records: TDnsRecordSeed[] = []; if (!options.dnsNsDomains) { return records; } // Determine the public IP for nameserver A records let publicIp: string | null = null; // Use proxy IPs if configured (these should be public IPs) if (options.proxyIps && options.proxyIps.length > 0) { publicIp = options.proxyIps[0]; // Use first proxy IP logger.log('info', `Using proxy IP for nameserver A records: ${publicIp}`); } else if (options.publicIp) { // Use explicitly configured public IP publicIp = options.publicIp; this.dcRouterRef.detectedPublicIp = publicIp; this.dcRouterRef.remoteIngressManager?.setHubPublicIps([options.publicIp, publicIp]); logger.log('info', `Using configured public IP for nameserver A records: ${publicIp}`); } else { // Auto-discover public IP using smartnetwork try { logger.log('info', 'Auto-discovering public IP address...'); const smartNetwork = new plugins.smartnetwork.SmartNetwork(); const publicIps = await smartNetwork.getPublicIps(); if (publicIps.v4) { publicIp = publicIps.v4; this.dcRouterRef.detectedPublicIp = publicIp; this.dcRouterRef.remoteIngressManager?.setHubPublicIps([options.publicIp, publicIp]); logger.log('info', `Auto-discovered public IPv4: ${publicIp}`); } else { logger.log('warn', 'Could not auto-discover public IPv4 address'); } } catch (error: unknown) { logger.log('error', `Failed to auto-discover public IP: ${(error as Error).message}`); } if (!publicIp) { logger.log('warn', 'No public IP available. Nameserver A records require either proxyIps, publicIp, or successful auto-discovery.'); } } // Generate A records for nameservers if we have a public IP if (publicIp) { for (const nsDomain of options.dnsNsDomains) { records.push({ name: nsDomain, type: 'A', value: publicIp, ttl: 3600 }); } logger.log('info', `Generated A records for ${options.dnsNsDomains.length} nameservers`); } // Apex NS records are owned by DnsManager (see the doc comment above), and // SOA is synthesized by smartdns from `primaryNameserver`. return records; } private extractDomain(recordName: string): string { // Handle wildcards if (recordName.startsWith('*.')) { recordName = recordName.substring(2); } return recordName; } private async applyProxyIpReplacement(records: TDnsRecordSeed[]): Promise { const options = this.dcRouterRef.options; if (!options.proxyIps || options.proxyIps.length === 0) { return; // No proxy IPs configured, skip replacement } // Get server's public IP const serverIp = await this.detectServerPublicIp(); if (!serverIp) { logger.log('warn', 'Could not detect server public IP, skipping proxy IP replacement'); return; } logger.log('info', `Applying proxy IP replacement. Server IP: ${serverIp}, Proxy IPs: ${options.proxyIps.join(', ')}`); let proxyIndex = 0; for (const record of records) { if (record.type === 'A' && record.value === serverIp && record.useIngressProxy !== false) { // Round-robin through proxy IPs const proxyIp = options.proxyIps[proxyIndex % options.proxyIps.length]; logger.log('info', `Replacing A record for ${record.name}: ${record.value} → ${proxyIp}`); record.value = proxyIp; proxyIndex++; } } } private async detectServerPublicIp(): Promise { try { const smartNetwork = new plugins.smartnetwork.SmartNetwork(); const publicIps = await smartNetwork.getPublicIps(); if (publicIps.v4) { return publicIps.v4; } return null; } catch (error: unknown) { logger.log('warn', `Failed to detect public IP: ${(error as Error).message}`); return null; } } }