import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; import { DnsAuthorityDoc, DomainDoc } from '../db/documents/index.js'; import type { IDnsAuthorityDrift, IDnsAuthoritySettings, IDnsAuthorityZone, IDnsDelegationProbe, TDnsAuthorityState, } from '../../ts_interfaces/data/dns-authority.js'; /** * Result of applying an authority change to the running process. * Reconciliation is all-or-nothing: a partial application is rolled back. */ export interface IDnsAuthorityMutationResult { success: boolean; message?: string; probe?: IDnsDelegationProbe; settings?: IDnsAuthoritySettings; } /** Re-derives runtime state after the effective authority set changed. */ export type TDnsAuthorityReconciler = (reasonArg: string) => Promise; const normalizeZone = (zoneArg: string): string => zoneArg.trim().toLowerCase().replace(/\.$/, ''); /** Strip the trailing dot smartdns/DoH returns on NS values. */ const normalizeNameserver = (nameserverArg: string): string => nameserverArg.trim().toLowerCase().replace(/\.$/, ''); const isUsableZoneName = (zoneArg: string): boolean => { if (!zoneArg || zoneArg.length > 253) return false; if (zoneArg.includes('*') || zoneArg.includes(' ') || zoneArg.includes(',')) return false; const labels = zoneArg.split('.'); if (labels.length < 2) return false; return labels.every((labelArg) => Boolean(labelArg) && labelArg.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(labelArg)); }; /** * DnsAuthorityManager — owns which zones dcrouter may answer for * authoritatively, and proves that claim rather than accepting it. * * Why this exists: `dnsScopes` was the last un-migrated bootstrap option in an * otherwise DB-driven router. It was read once at startup and had no mutation * path, so claiming a newly delegated zone required a restart — measured at * 30–60 s of total public outage. But simply making the declared list editable * through the ops API would have been worse than the disease: the domain * ownership predicate accepts zone coverage as *proof* precisely because a * caller cannot write it. A `setDnsScopes` mutation would let an operator append * any domain and manufacture that proof — the identical self-assertion vector * that `createDcrouterDomain` used to have. * * So a zone earns authority by delegation instead: its public NS records must * name our `dnsNsDomains`. An ops-API caller cannot fake that without actually * controlling the domain, so the proof stays unforgeable while becoming * mutable at runtime. It is also the comparison that was missing — claimed * authority versus real delegation, checked by the mechanism itself rather than * by an audit bolted on afterwards. * * **There is exactly one source of authority: this database document.** An * earlier revision kept bootstrap `dnsScopes` as an always-in-effect floor and * unioned it with the verified set. That is gone. A floor is a second * representation of the same fact, it can only be changed by a redeploy, and it * cannot be revoked through the API — all three of which are the properties * that produced the outage this path exists to prevent. * * Removing the floor makes the *unreadable database* case load-bearing, so it is * handled explicitly rather than by accident. Three situations, three answers: * * - **document missing** — a readable database that has never been seeded. The * authority set is known, and it is empty: claim nothing. Logged at `error` * with the remediation, and the startup drift audit enumerates every zone * delegated to us that we are refusing to serve, which is the worklist. * - **document present, no zones** — identical handling, different message: * somebody revoked everything, which is a legitimate state we must not * silently repopulate. * - **document unreadable** — the authority set is *unknown*. `start()` throws * rather than reporting an empty set, because rendering an unknown as "claim * nothing" is exactly how a transient database fault would take every zone * off the air. `DnsServerRuntime.setup()` then refuses to start on * `getState() === 'unavailable'`. That second check is not redundant: * `DnsServer.dependsOn('DnsManager')` only *orders* startup, and taskbuffer * starts later levels even when an earlier optional service failed, so the * consumer has to read the state itself. Both services stay failed and * dcrouter runs degraded until it is restarted with a readable database — * DNS down and visibly failed, rather than up and asserting nothing. * * Fail closed on authority, never silently on service: with an empty set the * DNS server still starts, still serves DoH, and still picks zones up the moment * one is verified — without a restart. */ export class DnsAuthorityManager { private verifiedZones: IDnsAuthorityZone[] = []; private updatedAt = 0; private updatedBy = ''; private state: TDnsAuthorityState = 'unavailable'; private reconciler?: TDnsAuthorityReconciler; constructor( private getExpectedNameservers: () => string[], ) {} // ========================================================================== // Lifecycle // ========================================================================== public async start(): Promise { let doc: DnsAuthorityDoc | null; try { doc = await DnsAuthorityDoc.load(); } catch (error: unknown) { // An unknown authority set is not an empty one. Refuse to start rather // than let every consumer read "no zones" as "revoke everything". this.state = 'unavailable'; this.verifiedZones = []; throw new Error( `DnsAuthorityManager: the stored DNS authority set is unreadable (${(error as Error).message}). ` + 'dcrouter serves DNS authority from the database alone, so an unreadable document leaves authority ' + 'unknown — refusing to start rather than claiming nothing for every zone.', ); } this.state = 'loaded'; this.verifiedZones = doc?.verifiedZones ? [...doc.verifiedZones] : []; this.updatedAt = doc?.updatedAt ?? 0; this.updatedBy = doc?.updatedBy ?? ''; if (this.verifiedZones.length > 0) { logger.log( 'info', `DnsAuthorityManager: ${this.verifiedZones.length} delegation-verified zone(s) loaded ` + `(${this.verifiedZones.map((entry) => normalizeZone(entry.zone)).join(', ')})`, { zone: 'dns' }, ); return; } // Known-empty. Loud, because every zone is unserved until something is // verified, and because this is the state a first deploy lands in. logger.log( 'error', doc ? 'DnsAuthorityManager: the DNS authority document exists but lists no verified zones. ' + 'dcrouter is authoritative for nothing and will REFUSE every query until a zone is verified.' : 'DnsAuthorityManager: no DNS authority document exists yet. dcrouter is authoritative for nothing ' + 'and will REFUSE every query until a zone is verified. Seed the document, or claim each delegated ' + 'zone with dns-authority:write (probe first, then verify).', { zone: 'dns', authorityState: 'empty' }, ); } public async stop(): Promise { this.verifiedZones = []; this.state = 'unavailable'; this.reconciler = undefined; } /** * Wire the callback that re-derives runtime state (zone handlers, route * certificate warnings, private-route overlay) after the effective set changes. */ public setReconciler(reconciler?: TDnsAuthorityReconciler): void { this.reconciler = reconciler; } // ========================================================================== // Reads // ========================================================================== /** * The authority set: delegation-verified zones, sorted so every consumer that * needs a stable "first zone" (the DNSSEC signing zone, for one) gets the same * answer across restarts regardless of database insertion order. * * This is what the ownership predicate consumes, and it is the only input to * it. There is no bootstrap contribution. */ public getEffectiveZoneNames(): string[] { const zones = new Set(); for (const verified of this.verifiedZones) { const normalized = normalizeZone(verified.zone); if (normalized) zones.add(normalized); } return [...zones].sort(); } /** Whether the stored authority set was readable when it was last loaded. */ public getState(): TDnsAuthorityState { return this.state; } public getSettings(): IDnsAuthoritySettings { return { zones: this.verifiedZones .map((entry) => ({ ...entry, zone: normalizeZone(entry.zone), origin: 'verified' as const })) .sort((a, b) => a.zone.localeCompare(b.zone)), state: this.state, expectedNameservers: this.getExpectedNameservers().map(normalizeNameserver).filter(Boolean), updatedAt: this.updatedAt, updatedBy: this.updatedBy, }; } // ========================================================================== // Delegation probe // ========================================================================== /** * Ask the public DNS whether `zone` is delegated to our nameservers. * * Deliberately uses `strategy: 'doh'` — a single DNS-over-HTTPS attempt * against a public resolver, with no system-resolver fallback. smartdns' * `getNameServers()` uses `dns.resolveNs`, i.e. the *system* resolver, which on * a dcrouter host may be dcrouter itself: it would happily answer with the very * NS records we generated, making the probe self-confirming. An independent * vantage point is the whole point of the proof. * * The three verdicts are kept distinct on purpose. A timeout is not evidence * that a zone is not ours; collapsing 'undeterminable' into 'not-delegated' * would let a transient resolver fault revoke authority. */ public async probeDelegation(zoneArg: string): Promise { const zone = normalizeZone(zoneArg); const expectedNameservers = this.getExpectedNameservers() .map(normalizeNameserver) .filter(Boolean); if (!isUsableZoneName(zone)) { return { zone, verdict: 'not-delegated', observedNameservers: [], expectedNameservers, detail: `'${zoneArg}' is not a usable zone name`, }; } if (expectedNameservers.length === 0) { return { zone, verdict: 'undeterminable', observedNameservers: [], expectedNameservers, detail: 'dnsNsDomains is not configured, so there is no nameserver identity to prove delegation against', }; } const smartdnsClient = new plugins.smartdns.dnsClientMod.Smartdns({ strategy: 'doh', allowDohFallback: false, timeoutMs: 10_000, }); let result: Awaited>; try { result = await smartdnsClient.queryRecords(zone, 'NS'); } catch (error: unknown) { return { zone, verdict: 'undeterminable', observedNameservers: [], expectedNameservers, detail: `delegation lookup threw: ${(error as Error).message}`, }; } if (result.status === 'error') { return { zone, verdict: 'undeterminable', observedNameservers: [], expectedNameservers, detail: `delegation lookup failed (${result.error.kind}/${result.error.code}): ${result.error.message}`, }; } if (result.status === 'missing') { return { zone, verdict: 'not-delegated', observedNameservers: [], expectedNameservers, detail: result.missingReason === 'nxdomain' ? `${zone} does not exist in public DNS` : `${zone} has no NS records in public DNS`, }; } const observedNameservers: string[] = [...new Set( result.records .map((record): string => normalizeNameserver(String(record.value ?? ''))) .filter((nameserver): nameserver is string => Boolean(nameserver)), )]; const matches = observedNameservers.filter((observed) => expectedNameservers.includes(observed)); if (matches.length === 0) { return { zone, verdict: 'not-delegated', observedNameservers, expectedNameservers, detail: `${zone} is delegated to ${observedNameservers.join(', ') || 'nothing'}, which does not include any of our nameservers (${expectedNameservers.join(', ')})`, }; } return { zone, verdict: 'delegated', observedNameservers, expectedNameservers, detail: `${zone} is delegated to ${matches.join(', ')}`, }; } // ========================================================================== // Mutations // ========================================================================== /** * Claim authority over a zone, but only against a positive delegation proof. * An undeterminable probe refuses the mutation — never assume either way. */ public async verifyZone(zoneArg: string, verifiedBy: string): Promise { const zone = normalizeZone(zoneArg); if (!isUsableZoneName(zone)) { return { success: false, message: `'${zoneArg}' is not a usable zone name` }; } const probe = await this.probeDelegation(zone); if (probe.verdict !== 'delegated') { return { success: false, probe, message: probe.verdict === 'undeterminable' ? `Cannot verify ${zone}: ${probe.detail}. Authority is not claimed on an undeterminable probe.` : `Cannot verify ${zone}: ${probe.detail}`, }; } const entry: IDnsAuthorityZone = { zone, origin: 'verified', verifiedAt: Date.now(), observedNameservers: probe.observedNameservers, verifiedBy, }; const nextZones = [ ...this.verifiedZones.filter((candidate) => normalizeZone(candidate.zone) !== zone), entry, ]; const applied = await this.applyZones(nextZones, verifiedBy, `authority claimed for ${zone}`); if (!applied.success) { return { ...applied, probe }; } logger.log( 'info', `DnsAuthorityManager: ${zone} verified as ours (delegated to ${probe.observedNameservers.join(', ')}) and applied without a restart`, { zone: 'dns', verifiedBy }, ); return { success: true, probe, settings: this.getSettings() }; } /** * Drop a verified zone. Every zone is revocable — there is no undroppable * deployment-declared floor any more, so withdrawing authority never needs a * redeploy and never leaves the router asserting a claim it cannot retract. */ public async revokeZone(zoneArg: string, updatedBy: string): Promise { const zone = normalizeZone(zoneArg); if (!this.verifiedZones.some((candidate) => normalizeZone(candidate.zone) === zone)) { return { success: false, message: `${zone} is not a verified zone` }; } const nextZones = this.verifiedZones.filter((candidate) => normalizeZone(candidate.zone) !== zone); const applied = await this.applyZones(nextZones, updatedBy, `authority revoked for ${zone}`); if (!applied.success) { return applied; } logger.log('info', `DnsAuthorityManager: authority over ${zone} revoked and torn down in-process`, { zone: 'dns', updatedBy, }); return { success: true, settings: this.getSettings() }; } /** * Persist a new zone set and reconcile the running process against it. * * Fail closed: if reconciliation throws, the previous set is restored in the * database and re-reconciled, so the router is never left half-converted. * Reconciliation is idempotent, which is what makes the rollback safe. */ private async applyZones( nextZones: IDnsAuthorityZone[], updatedBy: string, reason: string, ): Promise { const previousZones = [...this.verifiedZones]; const previousUpdatedAt = this.updatedAt; const previousUpdatedBy = this.updatedBy; this.verifiedZones = nextZones; this.updatedAt = Date.now(); this.updatedBy = updatedBy; try { await this.persist(); } catch (error: unknown) { this.verifiedZones = previousZones; this.updatedAt = previousUpdatedAt; this.updatedBy = previousUpdatedBy; return { success: false, message: `Failed to persist DNS authority: ${(error as Error).message}` }; } try { await this.reconciler?.(reason); } catch (error: unknown) { const applyError = (error as Error).message; this.verifiedZones = previousZones; this.updatedAt = previousUpdatedAt; this.updatedBy = previousUpdatedBy; let rollbackNote = ''; try { await this.persist(); await this.reconciler?.(`rollback of ${reason}`); } catch (rollbackError: unknown) { rollbackNote = ` Rollback also failed: ${(rollbackError as Error).message}. ` + 'Runtime DNS state may be inconsistent — restart dcrouter to rebuild it deterministically.'; logger.log('error', `DnsAuthorityManager: rollback failed after ${reason}.${rollbackNote}`, { zone: 'dns', }); } return { success: false, message: `Failed to apply DNS authority change (${reason}): ${applyError}. ` + `The change was rolled back and nothing was left half-applied.${rollbackNote}`, }; } return { success: true, settings: this.getSettings() }; } private async persist(): Promise { let doc = await DnsAuthorityDoc.load(); if (!doc) { doc = new DnsAuthorityDoc(); doc.settingsId = 'dns-authority-settings'; } doc.verifiedZones = this.verifiedZones; doc.updatedAt = this.updatedAt; doc.updatedBy = this.updatedBy; await doc.save(); } // ========================================================================== // Drift audit // ========================================================================== /** * Compare claimed authority against what is actually true, in every direction. * * This is the check whose absence let one zone sit declared in `dnsScopes` * while four live zones were delegated to our nameservers and unclaimed. * * Advisory by design. It never mutates the authority set: a resolver blip at * startup must not revoke authority for every zone and convert a transient * fault into the outage this whole path exists to avoid. Undeterminable probes * are skipped rather than reported as drift. */ public async auditDelegationDrift(): Promise { const drift: IDnsAuthorityDrift[] = []; const effectiveZones = new Set(this.getEffectiveZoneNames()); const domains = await DomainDoc.findAll(); const hostedZones = new Set( domains .filter((domainArg) => domainArg.source === 'dcrouter') .map((domainArg) => normalizeZone(domainArg.name)) .filter(Boolean), ); // Direction 1: a dcrouter-hosted zone delegated to us but not claimed. for (const zone of [...hostedZones].filter((zone) => !effectiveZones.has(zone))) { const probe = await this.probeDelegation(zone); if (probe.verdict !== 'delegated') continue; drift.push({ zone, kind: 'delegated-but-unclaimed', observedNameservers: probe.observedNameservers, expectedNameservers: probe.expectedNameservers, detail: `${zone} is delegated to ${probe.observedNameservers.join(', ')} but is not in the authority set, ` + 'so dcrouter refuses to serve it authoritatively and will not request certificates for it', }); } // Direction 2: a verified zone whose delegation moved away from us. for (const verified of this.verifiedZones) { const zone = normalizeZone(verified.zone); const probe = await this.probeDelegation(zone); if (probe.verdict !== 'not-delegated') continue; drift.push({ zone, kind: 'claimed-but-not-delegated', observedNameservers: probe.observedNameservers, expectedNameservers: probe.expectedNameservers, detail: `${zone} was verified at ${verified.verifiedAt ? new Date(verified.verifiedAt).toISOString() : 'an unknown time'} ` + `but its delegation now names ${probe.observedNameservers.join(', ') || 'nothing'}; ` + 'dcrouter is still claiming authority over a zone that is no longer delegated to it', }); } // Direction 3: a claimed zone with nothing behind it. Records and generated // apex NS both hang off a dcrouter-hosted DomainDoc, so authority without // one is a zone we answer for and have nothing to say about — a lame // delegation of our own making. Cheap to detect and impossible to notice // otherwise, because the zone REFUSES nothing and answers nothing. for (const zone of effectiveZones) { if (hostedZones.has(zone)) continue; drift.push({ zone, kind: 'verified-but-unhosted', observedNameservers: [], expectedNameservers: this.getExpectedNameservers().map(normalizeNameserver).filter(Boolean), detail: `${zone} is in the authority set but has no dcrouter-hosted domain, so no records and no ` + 'generated apex NS are served for it; create the domain in the DNS manager or revoke the authority claim', }); } return drift; } /** Run the audit and log every finding at `error`. Never throws. */ public async logDelegationDrift(): Promise { let drift: IDnsAuthorityDrift[]; try { drift = await this.auditDelegationDrift(); } catch (error: unknown) { logger.log( 'warn', `DnsAuthorityManager: delegation drift audit could not complete: ${(error as Error).message}`, { zone: 'dns' }, ); return; } for (const entry of drift) { logger.log('error', `DNS authority drift (${entry.kind}): ${entry.detail}`, { zone: 'dns', driftKind: entry.kind, driftZone: entry.zone, }); } if (drift.length === 0) { logger.log('info', 'DnsAuthorityManager: declared authority matches observed delegation', { zone: 'dns', }); } } }