@serve.zone/dcrouter 17.10.1 → 18.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +360 -360
  3. package/dist_ts/00_commitinfo_data.js +2 -2
  4. package/dist_ts/acme/acme-failure-classification.d.ts +64 -0
  5. package/dist_ts/acme/acme-failure-classification.js +114 -0
  6. package/dist_ts/acme/classes.smartacme-lifecycle.d.ts +42 -0
  7. package/dist_ts/acme/classes.smartacme-lifecycle.js +75 -4
  8. package/dist_ts/acme/index.d.ts +1 -0
  9. package/dist_ts/acme/index.js +2 -1
  10. package/dist_ts/classes.dcrouter.d.ts +33 -9
  11. package/dist_ts/classes.dcrouter.js +145 -11
  12. package/dist_ts/config/classes.route-config-manager.d.ts +56 -0
  13. package/dist_ts/config/classes.route-config-manager.js +161 -1
  14. package/dist_ts/db/documents/classes.dns-authority.doc.d.ts +30 -0
  15. package/dist_ts/db/documents/classes.dns-authority.doc.js +108 -0
  16. package/dist_ts/db/documents/index.d.ts +1 -0
  17. package/dist_ts/db/documents/index.js +2 -1
  18. package/dist_ts/dns/classes.dns-server-runtime.d.ts +109 -1
  19. package/dist_ts/dns/classes.dns-server-runtime.js +212 -34
  20. package/dist_ts/dns/classes.gateway-route-dns-reconciler.d.ts +7 -0
  21. package/dist_ts/dns/classes.gateway-route-dns-reconciler.js +37 -6
  22. package/dist_ts/dns/domain-ownership.d.ts +111 -0
  23. package/dist_ts/dns/domain-ownership.js +152 -0
  24. package/dist_ts/dns/index.d.ts +2 -0
  25. package/dist_ts/dns/index.js +3 -1
  26. package/dist_ts/dns/manager.dns-authority.d.ts +143 -0
  27. package/dist_ts/dns/manager.dns-authority.js +481 -0
  28. package/dist_ts/dns/manager.dns.d.ts +121 -12
  29. package/dist_ts/dns/manager.dns.js +298 -28
  30. package/dist_ts/errors/error.codes.d.ts +4 -0
  31. package/dist_ts/errors/error.codes.js +5 -1
  32. package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
  33. package/dist_ts/opsserver/classes.opsserver.js +3 -1
  34. package/dist_ts/opsserver/handlers/acme-config.handler.js +6 -1
  35. package/dist_ts/opsserver/handlers/certificate.handler.d.ts +16 -0
  36. package/dist_ts/opsserver/handlers/certificate.handler.js +96 -10
  37. package/dist_ts/opsserver/handlers/config.handler.js +4 -2
  38. package/dist_ts/opsserver/handlers/dns-authority.handler.d.ts +20 -0
  39. package/dist_ts/opsserver/handlers/dns-authority.handler.js +108 -0
  40. package/dist_ts/opsserver/handlers/dns-provider.handler.js +5 -1
  41. package/dist_ts/opsserver/handlers/domain.handler.js +9 -1
  42. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +2 -2
  43. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  44. package/dist_ts/opsserver/handlers/index.js +2 -1
  45. package/dist_ts_interfaces/data/dns-authority.d.ts +98 -0
  46. package/dist_ts_interfaces/data/dns-authority.js +26 -0
  47. package/dist_ts_interfaces/data/index.d.ts +1 -0
  48. package/dist_ts_interfaces/data/index.js +2 -1
  49. package/dist_ts_interfaces/data/route-management.d.ts +9 -2
  50. package/dist_ts_interfaces/data/route-management.js +3 -1
  51. package/dist_ts_interfaces/requests/certificate.d.ts +23 -0
  52. package/dist_ts_interfaces/requests/certificate.js +1 -1
  53. package/dist_ts_interfaces/requests/dns-authority.d.ts +80 -0
  54. package/dist_ts_interfaces/requests/dns-authority.js +3 -0
  55. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  56. package/dist_ts_interfaces/requests/index.js +2 -1
  57. package/dist_ts_migrations/index.js +122 -9
  58. package/dist_ts_oci_container/index.js +9 -4
  59. package/dist_ts_web/00_commitinfo_data.js +2 -2
  60. package/package.json +1 -1
  61. package/readme.hints.md +412 -0
  62. package/readme.md +48 -6
  63. package/ts/00_commitinfo_data.ts +1 -1
  64. package/ts/acme/acme-failure-classification.ts +201 -0
  65. package/ts/acme/classes.smartacme-lifecycle.ts +104 -3
  66. package/ts/acme/index.ts +1 -0
  67. package/ts/classes.dcrouter.ts +193 -23
  68. package/ts/config/classes.route-config-manager.ts +197 -0
  69. package/ts/db/documents/classes.dns-authority.doc.ts +49 -0
  70. package/ts/db/documents/index.ts +1 -0
  71. package/ts/dns/classes.dns-server-runtime.ts +257 -38
  72. package/ts/dns/classes.gateway-route-dns-reconciler.ts +41 -6
  73. package/ts/dns/domain-ownership.ts +272 -0
  74. package/ts/dns/index.ts +2 -0
  75. package/ts/dns/manager.dns-authority.ts +558 -0
  76. package/ts/dns/manager.dns.ts +373 -27
  77. package/ts/errors/error.codes.ts +4 -0
  78. package/ts/opsserver/classes.opsserver.ts +2 -0
  79. package/ts/opsserver/handlers/acme-config.handler.ts +7 -0
  80. package/ts/opsserver/handlers/certificate.handler.ts +103 -8
  81. package/ts/opsserver/handlers/config.handler.ts +3 -1
  82. package/ts/opsserver/handlers/dns-authority.handler.ts +142 -0
  83. package/ts/opsserver/handlers/dns-provider.handler.ts +6 -0
  84. package/ts/opsserver/handlers/domain.handler.ts +12 -0
  85. package/ts/opsserver/handlers/gatewayclient.handler.ts +1 -1
  86. package/ts/opsserver/handlers/index.ts +1 -0
  87. package/ts/readme.md +1 -1
  88. package/ts_web/00_commitinfo_data.ts +1 -1
@@ -0,0 +1,558 @@
1
+ import * as plugins from '../plugins.js';
2
+ import { logger } from '../logger.js';
3
+ import { DnsAuthorityDoc, DomainDoc } from '../db/documents/index.js';
4
+ import type {
5
+ IDnsAuthorityDrift,
6
+ IDnsAuthoritySettings,
7
+ IDnsAuthorityZone,
8
+ IDnsDelegationProbe,
9
+ TDnsAuthorityState,
10
+ } from '../../ts_interfaces/data/dns-authority.js';
11
+
12
+ /**
13
+ * Result of applying an authority change to the running process.
14
+ * Reconciliation is all-or-nothing: a partial application is rolled back.
15
+ */
16
+ export interface IDnsAuthorityMutationResult {
17
+ success: boolean;
18
+ message?: string;
19
+ probe?: IDnsDelegationProbe;
20
+ settings?: IDnsAuthoritySettings;
21
+ }
22
+
23
+ /** Re-derives runtime state after the effective authority set changed. */
24
+ export type TDnsAuthorityReconciler = (reasonArg: string) => Promise<void>;
25
+
26
+ const normalizeZone = (zoneArg: string): string =>
27
+ zoneArg.trim().toLowerCase().replace(/\.$/, '');
28
+
29
+ /** Strip the trailing dot smartdns/DoH returns on NS values. */
30
+ const normalizeNameserver = (nameserverArg: string): string =>
31
+ nameserverArg.trim().toLowerCase().replace(/\.$/, '');
32
+
33
+ const isUsableZoneName = (zoneArg: string): boolean => {
34
+ if (!zoneArg || zoneArg.length > 253) return false;
35
+ if (zoneArg.includes('*') || zoneArg.includes(' ') || zoneArg.includes(',')) return false;
36
+ const labels = zoneArg.split('.');
37
+ if (labels.length < 2) return false;
38
+ return labels.every((labelArg) => Boolean(labelArg)
39
+ && labelArg.length <= 63
40
+ && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(labelArg));
41
+ };
42
+
43
+ /**
44
+ * DnsAuthorityManager — owns which zones dcrouter may answer for
45
+ * authoritatively, and proves that claim rather than accepting it.
46
+ *
47
+ * Why this exists: `dnsScopes` was the last un-migrated bootstrap option in an
48
+ * otherwise DB-driven router. It was read once at startup and had no mutation
49
+ * path, so claiming a newly delegated zone required a restart — measured at
50
+ * 30–60 s of total public outage. But simply making the declared list editable
51
+ * through the ops API would have been worse than the disease: the domain
52
+ * ownership predicate accepts zone coverage as *proof* precisely because a
53
+ * caller cannot write it. A `setDnsScopes` mutation would let an operator append
54
+ * any domain and manufacture that proof — the identical self-assertion vector
55
+ * that `createDcrouterDomain` used to have.
56
+ *
57
+ * So a zone earns authority by delegation instead: its public NS records must
58
+ * name our `dnsNsDomains`. An ops-API caller cannot fake that without actually
59
+ * controlling the domain, so the proof stays unforgeable while becoming
60
+ * mutable at runtime. It is also the comparison that was missing — claimed
61
+ * authority versus real delegation, checked by the mechanism itself rather than
62
+ * by an audit bolted on afterwards.
63
+ *
64
+ * **There is exactly one source of authority: this database document.** An
65
+ * earlier revision kept bootstrap `dnsScopes` as an always-in-effect floor and
66
+ * unioned it with the verified set. That is gone. A floor is a second
67
+ * representation of the same fact, it can only be changed by a redeploy, and it
68
+ * cannot be revoked through the API — all three of which are the properties
69
+ * that produced the outage this path exists to prevent.
70
+ *
71
+ * Removing the floor makes the *unreadable database* case load-bearing, so it is
72
+ * handled explicitly rather than by accident. Three situations, three answers:
73
+ *
74
+ * - **document missing** — a readable database that has never been seeded. The
75
+ * authority set is known, and it is empty: claim nothing. Logged at `error`
76
+ * with the remediation, and the startup drift audit enumerates every zone
77
+ * delegated to us that we are refusing to serve, which is the worklist.
78
+ * - **document present, no zones** — identical handling, different message:
79
+ * somebody revoked everything, which is a legitimate state we must not
80
+ * silently repopulate.
81
+ * - **document unreadable** — the authority set is *unknown*. `start()` throws
82
+ * rather than reporting an empty set, because rendering an unknown as "claim
83
+ * nothing" is exactly how a transient database fault would take every zone
84
+ * off the air. `DnsServerRuntime.setup()` then refuses to start on
85
+ * `getState() === 'unavailable'`. That second check is not redundant:
86
+ * `DnsServer.dependsOn('DnsManager')` only *orders* startup, and taskbuffer
87
+ * starts later levels even when an earlier optional service failed, so the
88
+ * consumer has to read the state itself. Both services stay failed and
89
+ * dcrouter runs degraded until it is restarted with a readable database —
90
+ * DNS down and visibly failed, rather than up and asserting nothing.
91
+ *
92
+ * Fail closed on authority, never silently on service: with an empty set the
93
+ * DNS server still starts, still serves DoH, and still picks zones up the moment
94
+ * one is verified — without a restart.
95
+ */
96
+ export class DnsAuthorityManager {
97
+ private verifiedZones: IDnsAuthorityZone[] = [];
98
+ private updatedAt = 0;
99
+ private updatedBy = '';
100
+ private state: TDnsAuthorityState = 'unavailable';
101
+ private reconciler?: TDnsAuthorityReconciler;
102
+
103
+ constructor(
104
+ private getExpectedNameservers: () => string[],
105
+ ) {}
106
+
107
+ // ==========================================================================
108
+ // Lifecycle
109
+ // ==========================================================================
110
+
111
+ public async start(): Promise<void> {
112
+ let doc: DnsAuthorityDoc | null;
113
+ try {
114
+ doc = await DnsAuthorityDoc.load();
115
+ } catch (error: unknown) {
116
+ // An unknown authority set is not an empty one. Refuse to start rather
117
+ // than let every consumer read "no zones" as "revoke everything".
118
+ this.state = 'unavailable';
119
+ this.verifiedZones = [];
120
+ throw new Error(
121
+ `DnsAuthorityManager: the stored DNS authority set is unreadable (${(error as Error).message}). `
122
+ + 'dcrouter serves DNS authority from the database alone, so an unreadable document leaves authority '
123
+ + 'unknown — refusing to start rather than claiming nothing for every zone.',
124
+ );
125
+ }
126
+
127
+ this.state = 'loaded';
128
+ this.verifiedZones = doc?.verifiedZones ? [...doc.verifiedZones] : [];
129
+ this.updatedAt = doc?.updatedAt ?? 0;
130
+ this.updatedBy = doc?.updatedBy ?? '';
131
+
132
+ if (this.verifiedZones.length > 0) {
133
+ logger.log(
134
+ 'info',
135
+ `DnsAuthorityManager: ${this.verifiedZones.length} delegation-verified zone(s) loaded `
136
+ + `(${this.verifiedZones.map((entry) => normalizeZone(entry.zone)).join(', ')})`,
137
+ { zone: 'dns' },
138
+ );
139
+ return;
140
+ }
141
+
142
+ // Known-empty. Loud, because every zone is unserved until something is
143
+ // verified, and because this is the state a first deploy lands in.
144
+ logger.log(
145
+ 'error',
146
+ doc
147
+ ? 'DnsAuthorityManager: the DNS authority document exists but lists no verified zones. '
148
+ + 'dcrouter is authoritative for nothing and will REFUSE every query until a zone is verified.'
149
+ : 'DnsAuthorityManager: no DNS authority document exists yet. dcrouter is authoritative for nothing '
150
+ + 'and will REFUSE every query until a zone is verified. Seed the document, or claim each delegated '
151
+ + 'zone with dns-authority:write (probe first, then verify).',
152
+ { zone: 'dns', authorityState: 'empty' },
153
+ );
154
+ }
155
+
156
+ public async stop(): Promise<void> {
157
+ this.verifiedZones = [];
158
+ this.state = 'unavailable';
159
+ this.reconciler = undefined;
160
+ }
161
+
162
+ /**
163
+ * Wire the callback that re-derives runtime state (zone handlers, route
164
+ * certificate warnings, private-route overlay) after the effective set changes.
165
+ */
166
+ public setReconciler(reconciler?: TDnsAuthorityReconciler): void {
167
+ this.reconciler = reconciler;
168
+ }
169
+
170
+ // ==========================================================================
171
+ // Reads
172
+ // ==========================================================================
173
+
174
+ /**
175
+ * The authority set: delegation-verified zones, sorted so every consumer that
176
+ * needs a stable "first zone" (the DNSSEC signing zone, for one) gets the same
177
+ * answer across restarts regardless of database insertion order.
178
+ *
179
+ * This is what the ownership predicate consumes, and it is the only input to
180
+ * it. There is no bootstrap contribution.
181
+ */
182
+ public getEffectiveZoneNames(): string[] {
183
+ const zones = new Set<string>();
184
+ for (const verified of this.verifiedZones) {
185
+ const normalized = normalizeZone(verified.zone);
186
+ if (normalized) zones.add(normalized);
187
+ }
188
+ return [...zones].sort();
189
+ }
190
+
191
+ /** Whether the stored authority set was readable when it was last loaded. */
192
+ public getState(): TDnsAuthorityState {
193
+ return this.state;
194
+ }
195
+
196
+ public getSettings(): IDnsAuthoritySettings {
197
+ return {
198
+ zones: this.verifiedZones
199
+ .map((entry) => ({ ...entry, zone: normalizeZone(entry.zone), origin: 'verified' as const }))
200
+ .sort((a, b) => a.zone.localeCompare(b.zone)),
201
+ state: this.state,
202
+ expectedNameservers: this.getExpectedNameservers().map(normalizeNameserver).filter(Boolean),
203
+ updatedAt: this.updatedAt,
204
+ updatedBy: this.updatedBy,
205
+ };
206
+ }
207
+
208
+ // ==========================================================================
209
+ // Delegation probe
210
+ // ==========================================================================
211
+
212
+ /**
213
+ * Ask the public DNS whether `zone` is delegated to our nameservers.
214
+ *
215
+ * Deliberately uses `strategy: 'doh'` — a single DNS-over-HTTPS attempt
216
+ * against a public resolver, with no system-resolver fallback. smartdns'
217
+ * `getNameServers()` uses `dns.resolveNs`, i.e. the *system* resolver, which on
218
+ * a dcrouter host may be dcrouter itself: it would happily answer with the very
219
+ * NS records we generated, making the probe self-confirming. An independent
220
+ * vantage point is the whole point of the proof.
221
+ *
222
+ * The three verdicts are kept distinct on purpose. A timeout is not evidence
223
+ * that a zone is not ours; collapsing 'undeterminable' into 'not-delegated'
224
+ * would let a transient resolver fault revoke authority.
225
+ */
226
+ public async probeDelegation(zoneArg: string): Promise<IDnsDelegationProbe> {
227
+ const zone = normalizeZone(zoneArg);
228
+ const expectedNameservers = this.getExpectedNameservers()
229
+ .map(normalizeNameserver)
230
+ .filter(Boolean);
231
+
232
+ if (!isUsableZoneName(zone)) {
233
+ return {
234
+ zone,
235
+ verdict: 'not-delegated',
236
+ observedNameservers: [],
237
+ expectedNameservers,
238
+ detail: `'${zoneArg}' is not a usable zone name`,
239
+ };
240
+ }
241
+ if (expectedNameservers.length === 0) {
242
+ return {
243
+ zone,
244
+ verdict: 'undeterminable',
245
+ observedNameservers: [],
246
+ expectedNameservers,
247
+ detail: 'dnsNsDomains is not configured, so there is no nameserver identity to prove delegation against',
248
+ };
249
+ }
250
+
251
+ const smartdnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
252
+ strategy: 'doh',
253
+ allowDohFallback: false,
254
+ timeoutMs: 10_000,
255
+ });
256
+
257
+ let result: Awaited<ReturnType<typeof smartdnsClient.queryRecords>>;
258
+ try {
259
+ result = await smartdnsClient.queryRecords(zone, 'NS');
260
+ } catch (error: unknown) {
261
+ return {
262
+ zone,
263
+ verdict: 'undeterminable',
264
+ observedNameservers: [],
265
+ expectedNameservers,
266
+ detail: `delegation lookup threw: ${(error as Error).message}`,
267
+ };
268
+ }
269
+
270
+ if (result.status === 'error') {
271
+ return {
272
+ zone,
273
+ verdict: 'undeterminable',
274
+ observedNameservers: [],
275
+ expectedNameservers,
276
+ detail: `delegation lookup failed (${result.error.kind}/${result.error.code}): ${result.error.message}`,
277
+ };
278
+ }
279
+
280
+ if (result.status === 'missing') {
281
+ return {
282
+ zone,
283
+ verdict: 'not-delegated',
284
+ observedNameservers: [],
285
+ expectedNameservers,
286
+ detail: result.missingReason === 'nxdomain'
287
+ ? `${zone} does not exist in public DNS`
288
+ : `${zone} has no NS records in public DNS`,
289
+ };
290
+ }
291
+
292
+ const observedNameservers: string[] = [...new Set<string>(
293
+ result.records
294
+ .map((record): string => normalizeNameserver(String(record.value ?? '')))
295
+ .filter((nameserver): nameserver is string => Boolean(nameserver)),
296
+ )];
297
+ const matches = observedNameservers.filter((observed) => expectedNameservers.includes(observed));
298
+ if (matches.length === 0) {
299
+ return {
300
+ zone,
301
+ verdict: 'not-delegated',
302
+ observedNameservers,
303
+ expectedNameservers,
304
+ detail: `${zone} is delegated to ${observedNameservers.join(', ') || 'nothing'}, which does not include any of our nameservers (${expectedNameservers.join(', ')})`,
305
+ };
306
+ }
307
+ return {
308
+ zone,
309
+ verdict: 'delegated',
310
+ observedNameservers,
311
+ expectedNameservers,
312
+ detail: `${zone} is delegated to ${matches.join(', ')}`,
313
+ };
314
+ }
315
+
316
+ // ==========================================================================
317
+ // Mutations
318
+ // ==========================================================================
319
+
320
+ /**
321
+ * Claim authority over a zone, but only against a positive delegation proof.
322
+ * An undeterminable probe refuses the mutation — never assume either way.
323
+ */
324
+ public async verifyZone(zoneArg: string, verifiedBy: string): Promise<IDnsAuthorityMutationResult> {
325
+ const zone = normalizeZone(zoneArg);
326
+ if (!isUsableZoneName(zone)) {
327
+ return { success: false, message: `'${zoneArg}' is not a usable zone name` };
328
+ }
329
+
330
+ const probe = await this.probeDelegation(zone);
331
+ if (probe.verdict !== 'delegated') {
332
+ return {
333
+ success: false,
334
+ probe,
335
+ message: probe.verdict === 'undeterminable'
336
+ ? `Cannot verify ${zone}: ${probe.detail}. Authority is not claimed on an undeterminable probe.`
337
+ : `Cannot verify ${zone}: ${probe.detail}`,
338
+ };
339
+ }
340
+
341
+ const entry: IDnsAuthorityZone = {
342
+ zone,
343
+ origin: 'verified',
344
+ verifiedAt: Date.now(),
345
+ observedNameservers: probe.observedNameservers,
346
+ verifiedBy,
347
+ };
348
+ const nextZones = [
349
+ ...this.verifiedZones.filter((candidate) => normalizeZone(candidate.zone) !== zone),
350
+ entry,
351
+ ];
352
+ const applied = await this.applyZones(nextZones, verifiedBy, `authority claimed for ${zone}`);
353
+ if (!applied.success) {
354
+ return { ...applied, probe };
355
+ }
356
+ logger.log(
357
+ 'info',
358
+ `DnsAuthorityManager: ${zone} verified as ours (delegated to ${probe.observedNameservers.join(', ')}) and applied without a restart`,
359
+ { zone: 'dns', verifiedBy },
360
+ );
361
+ return { success: true, probe, settings: this.getSettings() };
362
+ }
363
+
364
+ /**
365
+ * Drop a verified zone. Every zone is revocable — there is no undroppable
366
+ * deployment-declared floor any more, so withdrawing authority never needs a
367
+ * redeploy and never leaves the router asserting a claim it cannot retract.
368
+ */
369
+ public async revokeZone(zoneArg: string, updatedBy: string): Promise<IDnsAuthorityMutationResult> {
370
+ const zone = normalizeZone(zoneArg);
371
+ if (!this.verifiedZones.some((candidate) => normalizeZone(candidate.zone) === zone)) {
372
+ return { success: false, message: `${zone} is not a verified zone` };
373
+ }
374
+ const nextZones = this.verifiedZones.filter((candidate) => normalizeZone(candidate.zone) !== zone);
375
+ const applied = await this.applyZones(nextZones, updatedBy, `authority revoked for ${zone}`);
376
+ if (!applied.success) {
377
+ return applied;
378
+ }
379
+ logger.log('info', `DnsAuthorityManager: authority over ${zone} revoked and torn down in-process`, {
380
+ zone: 'dns',
381
+ updatedBy,
382
+ });
383
+ return { success: true, settings: this.getSettings() };
384
+ }
385
+
386
+ /**
387
+ * Persist a new zone set and reconcile the running process against it.
388
+ *
389
+ * Fail closed: if reconciliation throws, the previous set is restored in the
390
+ * database and re-reconciled, so the router is never left half-converted.
391
+ * Reconciliation is idempotent, which is what makes the rollback safe.
392
+ */
393
+ private async applyZones(
394
+ nextZones: IDnsAuthorityZone[],
395
+ updatedBy: string,
396
+ reason: string,
397
+ ): Promise<IDnsAuthorityMutationResult> {
398
+ const previousZones = [...this.verifiedZones];
399
+ const previousUpdatedAt = this.updatedAt;
400
+ const previousUpdatedBy = this.updatedBy;
401
+
402
+ this.verifiedZones = nextZones;
403
+ this.updatedAt = Date.now();
404
+ this.updatedBy = updatedBy;
405
+
406
+ try {
407
+ await this.persist();
408
+ } catch (error: unknown) {
409
+ this.verifiedZones = previousZones;
410
+ this.updatedAt = previousUpdatedAt;
411
+ this.updatedBy = previousUpdatedBy;
412
+ return { success: false, message: `Failed to persist DNS authority: ${(error as Error).message}` };
413
+ }
414
+
415
+ try {
416
+ await this.reconciler?.(reason);
417
+ } catch (error: unknown) {
418
+ const applyError = (error as Error).message;
419
+ this.verifiedZones = previousZones;
420
+ this.updatedAt = previousUpdatedAt;
421
+ this.updatedBy = previousUpdatedBy;
422
+ let rollbackNote = '';
423
+ try {
424
+ await this.persist();
425
+ await this.reconciler?.(`rollback of ${reason}`);
426
+ } catch (rollbackError: unknown) {
427
+ rollbackNote = ` Rollback also failed: ${(rollbackError as Error).message}. `
428
+ + 'Runtime DNS state may be inconsistent — restart dcrouter to rebuild it deterministically.';
429
+ logger.log('error', `DnsAuthorityManager: rollback failed after ${reason}.${rollbackNote}`, {
430
+ zone: 'dns',
431
+ });
432
+ }
433
+ return {
434
+ success: false,
435
+ message: `Failed to apply DNS authority change (${reason}): ${applyError}. `
436
+ + `The change was rolled back and nothing was left half-applied.${rollbackNote}`,
437
+ };
438
+ }
439
+
440
+ return { success: true, settings: this.getSettings() };
441
+ }
442
+
443
+ private async persist(): Promise<void> {
444
+ let doc = await DnsAuthorityDoc.load();
445
+ if (!doc) {
446
+ doc = new DnsAuthorityDoc();
447
+ doc.settingsId = 'dns-authority-settings';
448
+ }
449
+ doc.verifiedZones = this.verifiedZones;
450
+ doc.updatedAt = this.updatedAt;
451
+ doc.updatedBy = this.updatedBy;
452
+ await doc.save();
453
+ }
454
+
455
+ // ==========================================================================
456
+ // Drift audit
457
+ // ==========================================================================
458
+
459
+ /**
460
+ * Compare claimed authority against what is actually true, in every direction.
461
+ *
462
+ * This is the check whose absence let one zone sit declared in `dnsScopes`
463
+ * while four live zones were delegated to our nameservers and unclaimed.
464
+ *
465
+ * Advisory by design. It never mutates the authority set: a resolver blip at
466
+ * startup must not revoke authority for every zone and convert a transient
467
+ * fault into the outage this whole path exists to avoid. Undeterminable probes
468
+ * are skipped rather than reported as drift.
469
+ */
470
+ public async auditDelegationDrift(): Promise<IDnsAuthorityDrift[]> {
471
+ const drift: IDnsAuthorityDrift[] = [];
472
+ const effectiveZones = new Set(this.getEffectiveZoneNames());
473
+
474
+ const domains = await DomainDoc.findAll();
475
+ const hostedZones = new Set(
476
+ domains
477
+ .filter((domainArg) => domainArg.source === 'dcrouter')
478
+ .map((domainArg) => normalizeZone(domainArg.name))
479
+ .filter(Boolean),
480
+ );
481
+
482
+ // Direction 1: a dcrouter-hosted zone delegated to us but not claimed.
483
+ for (const zone of [...hostedZones].filter((zone) => !effectiveZones.has(zone))) {
484
+ const probe = await this.probeDelegation(zone);
485
+ if (probe.verdict !== 'delegated') continue;
486
+ drift.push({
487
+ zone,
488
+ kind: 'delegated-but-unclaimed',
489
+ observedNameservers: probe.observedNameservers,
490
+ expectedNameservers: probe.expectedNameservers,
491
+ detail: `${zone} is delegated to ${probe.observedNameservers.join(', ')} but is not in the authority set, `
492
+ + 'so dcrouter refuses to serve it authoritatively and will not request certificates for it',
493
+ });
494
+ }
495
+
496
+ // Direction 2: a verified zone whose delegation moved away from us.
497
+ for (const verified of this.verifiedZones) {
498
+ const zone = normalizeZone(verified.zone);
499
+ const probe = await this.probeDelegation(zone);
500
+ if (probe.verdict !== 'not-delegated') continue;
501
+ drift.push({
502
+ zone,
503
+ kind: 'claimed-but-not-delegated',
504
+ observedNameservers: probe.observedNameservers,
505
+ expectedNameservers: probe.expectedNameservers,
506
+ detail: `${zone} was verified at ${verified.verifiedAt ? new Date(verified.verifiedAt).toISOString() : 'an unknown time'} `
507
+ + `but its delegation now names ${probe.observedNameservers.join(', ') || 'nothing'}; `
508
+ + 'dcrouter is still claiming authority over a zone that is no longer delegated to it',
509
+ });
510
+ }
511
+
512
+ // Direction 3: a claimed zone with nothing behind it. Records and generated
513
+ // apex NS both hang off a dcrouter-hosted DomainDoc, so authority without
514
+ // one is a zone we answer for and have nothing to say about — a lame
515
+ // delegation of our own making. Cheap to detect and impossible to notice
516
+ // otherwise, because the zone REFUSES nothing and answers nothing.
517
+ for (const zone of effectiveZones) {
518
+ if (hostedZones.has(zone)) continue;
519
+ drift.push({
520
+ zone,
521
+ kind: 'verified-but-unhosted',
522
+ observedNameservers: [],
523
+ expectedNameservers: this.getExpectedNameservers().map(normalizeNameserver).filter(Boolean),
524
+ detail: `${zone} is in the authority set but has no dcrouter-hosted domain, so no records and no `
525
+ + 'generated apex NS are served for it; create the domain in the DNS manager or revoke the authority claim',
526
+ });
527
+ }
528
+
529
+ return drift;
530
+ }
531
+
532
+ /** Run the audit and log every finding at `error`. Never throws. */
533
+ public async logDelegationDrift(): Promise<void> {
534
+ let drift: IDnsAuthorityDrift[];
535
+ try {
536
+ drift = await this.auditDelegationDrift();
537
+ } catch (error: unknown) {
538
+ logger.log(
539
+ 'warn',
540
+ `DnsAuthorityManager: delegation drift audit could not complete: ${(error as Error).message}`,
541
+ { zone: 'dns' },
542
+ );
543
+ return;
544
+ }
545
+ for (const entry of drift) {
546
+ logger.log('error', `DNS authority drift (${entry.kind}): ${entry.detail}`, {
547
+ zone: 'dns',
548
+ driftKind: entry.kind,
549
+ driftZone: entry.zone,
550
+ });
551
+ }
552
+ if (drift.length === 0) {
553
+ logger.log('info', 'DnsAuthorityManager: declared authority matches observed delegation', {
554
+ zone: 'dns',
555
+ });
556
+ }
557
+ }
558
+ }