@serve.zone/dcrouter 17.10.2 → 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 (84) 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/domain-ownership.d.ts +111 -0
  21. package/dist_ts/dns/domain-ownership.js +152 -0
  22. package/dist_ts/dns/index.d.ts +2 -0
  23. package/dist_ts/dns/index.js +3 -1
  24. package/dist_ts/dns/manager.dns-authority.d.ts +143 -0
  25. package/dist_ts/dns/manager.dns-authority.js +481 -0
  26. package/dist_ts/dns/manager.dns.d.ts +121 -12
  27. package/dist_ts/dns/manager.dns.js +298 -28
  28. package/dist_ts/errors/error.codes.d.ts +4 -0
  29. package/dist_ts/errors/error.codes.js +5 -1
  30. package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
  31. package/dist_ts/opsserver/classes.opsserver.js +3 -1
  32. package/dist_ts/opsserver/handlers/acme-config.handler.js +6 -1
  33. package/dist_ts/opsserver/handlers/certificate.handler.d.ts +16 -0
  34. package/dist_ts/opsserver/handlers/certificate.handler.js +96 -10
  35. package/dist_ts/opsserver/handlers/config.handler.js +4 -2
  36. package/dist_ts/opsserver/handlers/dns-authority.handler.d.ts +20 -0
  37. package/dist_ts/opsserver/handlers/dns-authority.handler.js +108 -0
  38. package/dist_ts/opsserver/handlers/dns-provider.handler.js +5 -1
  39. package/dist_ts/opsserver/handlers/domain.handler.js +9 -1
  40. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +2 -2
  41. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  42. package/dist_ts/opsserver/handlers/index.js +2 -1
  43. package/dist_ts_interfaces/data/dns-authority.d.ts +98 -0
  44. package/dist_ts_interfaces/data/dns-authority.js +26 -0
  45. package/dist_ts_interfaces/data/index.d.ts +1 -0
  46. package/dist_ts_interfaces/data/index.js +2 -1
  47. package/dist_ts_interfaces/data/route-management.d.ts +9 -2
  48. package/dist_ts_interfaces/data/route-management.js +3 -1
  49. package/dist_ts_interfaces/requests/certificate.d.ts +23 -0
  50. package/dist_ts_interfaces/requests/certificate.js +1 -1
  51. package/dist_ts_interfaces/requests/dns-authority.d.ts +80 -0
  52. package/dist_ts_interfaces/requests/dns-authority.js +3 -0
  53. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  54. package/dist_ts_interfaces/requests/index.js +2 -1
  55. package/dist_ts_oci_container/index.js +9 -4
  56. package/dist_ts_web/00_commitinfo_data.js +2 -2
  57. package/package.json +1 -1
  58. package/readme.hints.md +412 -0
  59. package/readme.md +48 -6
  60. package/ts/00_commitinfo_data.ts +1 -1
  61. package/ts/acme/acme-failure-classification.ts +201 -0
  62. package/ts/acme/classes.smartacme-lifecycle.ts +104 -3
  63. package/ts/acme/index.ts +1 -0
  64. package/ts/classes.dcrouter.ts +193 -23
  65. package/ts/config/classes.route-config-manager.ts +197 -0
  66. package/ts/db/documents/classes.dns-authority.doc.ts +49 -0
  67. package/ts/db/documents/index.ts +1 -0
  68. package/ts/dns/classes.dns-server-runtime.ts +257 -38
  69. package/ts/dns/domain-ownership.ts +272 -0
  70. package/ts/dns/index.ts +2 -0
  71. package/ts/dns/manager.dns-authority.ts +558 -0
  72. package/ts/dns/manager.dns.ts +373 -27
  73. package/ts/errors/error.codes.ts +4 -0
  74. package/ts/opsserver/classes.opsserver.ts +2 -0
  75. package/ts/opsserver/handlers/acme-config.handler.ts +7 -0
  76. package/ts/opsserver/handlers/certificate.handler.ts +103 -8
  77. package/ts/opsserver/handlers/config.handler.ts +3 -1
  78. package/ts/opsserver/handlers/dns-authority.handler.ts +142 -0
  79. package/ts/opsserver/handlers/dns-provider.handler.ts +6 -0
  80. package/ts/opsserver/handlers/domain.handler.ts +12 -0
  81. package/ts/opsserver/handlers/gatewayclient.handler.ts +1 -1
  82. package/ts/opsserver/handlers/index.ts +1 -0
  83. package/ts/readme.md +1 -1
  84. package/ts_web/00_commitinfo_data.ts +1 -1
@@ -18,6 +18,28 @@ import type {
18
18
  IDnsProviderPublic,
19
19
  IProviderDomainListing,
20
20
  } from '../../ts_interfaces/data/dns-provider.js';
21
+ import {
22
+ resolveDomainOwnership,
23
+ type IDomainOwnershipZone,
24
+ type TDomainOwnership,
25
+ } from './domain-ownership.js';
26
+
27
+ /**
28
+ * Where a runtime DnsServer handler came from.
29
+ *
30
+ * - 'persisted' → rebuilt from a DnsRecordDoc row.
31
+ * - 'generated-default' → synthesised by dcrouter (apex NS today; SOA and other
32
+ * zone defaults would join this origin), so it has no DB row to enumerate and
33
+ * must be torn down from the registry.
34
+ */
35
+ export type TDnsRuntimeRegistrationOrigin = 'persisted' | 'generated-default';
36
+
37
+ /** One runtime DnsServer handler key owned by a DomainDoc. */
38
+ export interface IDnsRuntimeRegistration {
39
+ name: string;
40
+ type: TDnsRecordType;
41
+ origin: TDnsRuntimeRegistrationOrigin;
42
+ }
21
43
 
22
44
  /**
23
45
  * DnsManager — owns runtime DNS state on top of the embedded DnsServer.
@@ -36,7 +58,7 @@ import type {
36
58
  export class DnsManager {
37
59
  /**
38
60
  * Reference to the active smartdns DnsServer (set by DcRouter once it exists).
39
- * May be undefined if dnsScopes/dnsNsDomains aren't configured.
61
+ * May be undefined if dnsNsDomains isn't configured.
40
62
  */
41
63
  public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer;
42
64
 
@@ -46,8 +68,94 @@ export class DnsManager {
46
68
  */
47
69
  private providerClients = new Map<string, IDnsProviderClient>();
48
70
 
71
+ /**
72
+ * Per-domain registry of the runtime DnsServer handlers this manager owns,
73
+ * keyed by DomainDoc.id then by `name|type`.
74
+ *
75
+ * Teardown must remove exactly what registration added — no more, no less.
76
+ * smartdns' `unregisterHandler(pattern, types)` removes *every* handler for a
77
+ * pattern/type pair, including the nameserver glue handlers owned by
78
+ * DnsServerRuntime and the global wildcard A handler. Reconstructing "would I
79
+ * have registered this?" at delete time is therefore unsafe: any drift in the
80
+ * inputs (authority changed, ownership changed, a record row vanished) makes the
81
+ * reconstruction tear down another owner's handlers or miss its own. Recording
82
+ * the actual registrations, with their origin, removes that class of bug.
83
+ */
84
+ private runtimeRegistrations = new Map<string, Map<string, IDnsRuntimeRegistration>>();
85
+
86
+ /** Supplies the effective authority set (bootstrap ∪ delegation-verified). */
87
+ private authorityZonesResolver?: () => string[];
88
+
49
89
  constructor(private options: IDcRouterOptions) {}
50
90
 
91
+ // ==========================================================================
92
+ // Runtime registration registry
93
+ // ==========================================================================
94
+
95
+ private trackRuntimeRegistration(
96
+ domainId: string,
97
+ name: string,
98
+ type: TDnsRecordType,
99
+ origin: TDnsRuntimeRegistrationOrigin,
100
+ ): void {
101
+ let owned = this.runtimeRegistrations.get(domainId);
102
+ if (!owned) {
103
+ owned = new Map<string, IDnsRuntimeRegistration>();
104
+ this.runtimeRegistrations.set(domainId, owned);
105
+ }
106
+ owned.set(this.rrsetKey(name, type), { name: name.toLowerCase(), type, origin });
107
+ }
108
+
109
+ private untrackRuntimeRegistration(
110
+ domainId: string,
111
+ name: string,
112
+ type: TDnsRecordType,
113
+ ): void {
114
+ const owned = this.runtimeRegistrations.get(domainId);
115
+ if (!owned) return;
116
+ owned.delete(this.rrsetKey(name, type));
117
+ if (owned.size === 0) {
118
+ this.runtimeRegistrations.delete(domainId);
119
+ }
120
+ }
121
+
122
+ /** Registrations currently owned by a domain. Exposed for teardown assertions. */
123
+ public listRuntimeRegistrations(domainId: string): IDnsRuntimeRegistration[] {
124
+ return [...(this.runtimeRegistrations.get(domainId)?.values() || [])];
125
+ }
126
+
127
+ /**
128
+ * Unregister every runtime handler owned by a domain and forget them.
129
+ *
130
+ * Called from every path that ends dcrouter's authority over a zone. Without
131
+ * it, deleting a DomainDoc removed the DB source of truth (so record queries
132
+ * started answering REFUSED) while the generated apex NS handler kept answering
133
+ * `aa` with our nameservers until the process was restarted — stale authority
134
+ * live in memory, with nothing telling the next operator to restart. A restart
135
+ * is not an acceptable delete procedure: it costs 30–60 s of total public
136
+ * outage.
137
+ */
138
+ private tearDownDomainRuntimeRegistrations(domainId: string): void {
139
+ const owned = this.runtimeRegistrations.get(domainId);
140
+ this.runtimeRegistrations.delete(domainId);
141
+ if (!owned || !this.dnsServer) {
142
+ return;
143
+ }
144
+ const generatedZones: string[] = [];
145
+ for (const registration of owned.values()) {
146
+ this.dnsServer.unregisterHandler(registration.name, [registration.type]);
147
+ if (registration.origin === 'generated-default') {
148
+ generatedZones.push(`${registration.name} ${registration.type}`);
149
+ }
150
+ }
151
+ logger.log(
152
+ 'info',
153
+ `DnsManager: unregistered ${owned.size} runtime DNS handler key(s) for domain ${domainId}`
154
+ + (generatedZones.length ? `, including generated default(s) ${generatedZones.join(', ')}` : ''),
155
+ { zone: 'dns', domainId },
156
+ );
157
+ }
158
+
51
159
  // ==========================================================================
52
160
  // Lifecycle
53
161
  // ==========================================================================
@@ -58,6 +166,7 @@ export class DnsManager {
58
166
 
59
167
  public async stop(): Promise<void> {
60
168
  this.providerClients.clear();
169
+ this.runtimeRegistrations.clear();
61
170
  this.dnsServer = undefined;
62
171
  }
63
172
 
@@ -84,6 +193,12 @@ export class DnsManager {
84
193
  return;
85
194
  }
86
195
  const allDomains = await DomainDoc.findAll();
196
+ // One ownership snapshot for the whole startup pass, not one scan per zone.
197
+ const ownershipZones: IDomainOwnershipZone[] = allDomains.map((domainArg) => ({
198
+ name: domainArg.name,
199
+ source: domainArg.source,
200
+ providerId: domainArg.providerId,
201
+ }));
87
202
  const dcrouterDomains = allDomains.filter((d) => d.source === 'dcrouter');
88
203
  let registered = 0;
89
204
  for (const domain of dcrouterDomains) {
@@ -94,7 +209,7 @@ export class DnsManager {
94
209
  await this.refreshLocalRrset(domain.id, name, type);
95
210
  }
96
211
  registered += records.length;
97
- await this.registerAuthoritativeZoneDefaults(domain);
212
+ await this.registerAuthoritativeZoneDefaults(domain, ownershipZones);
98
213
  }
99
214
  logger.log(
100
215
  'info',
@@ -104,12 +219,22 @@ export class DnsManager {
104
219
 
105
220
  /**
106
221
  * Authoritative zones must answer apex NS queries or their delegation is
107
- * lame — Let's Encrypt's DNS-01 resolver SERVFAILs on such zones. Static
108
- * dnsScopes zones get NS records in DnsServerRuntime; zones registered in
109
- * the DB get them here, served from options.dnsNsDomains unless explicit
110
- * apex NS records exist in the DB.
222
+ * lame — Let's Encrypt's DNS-01 resolver SERVFAILs on such zones. This is the
223
+ * only place generated apex NS records come from, for every authoritative
224
+ * zone: they are served from options.dnsNsDomains unless explicit apex NS
225
+ * records exist in the DB. DnsServerRuntime used to emit a static set for
226
+ * bootstrap `dnsScopes` zones, which could neither appear for a zone verified
227
+ * after startup nor disappear for one whose authority was revoked.
228
+ *
229
+ * Ownership is verified first. smartdns marks any answer a registered handler
230
+ * produces as authoritative (`aa`) regardless of `authoritativeZones`, so
231
+ * registering this handler for a zone we cannot prove we own makes dcrouter
232
+ * publicly claim authority over somebody else's domain.
111
233
  */
112
- private async registerAuthoritativeZoneDefaults(domain: DomainDoc): Promise<void> {
234
+ private async registerAuthoritativeZoneDefaults(
235
+ domain: DomainDoc,
236
+ ownershipZonesArg?: IDomainOwnershipZone[],
237
+ ): Promise<void> {
113
238
  if (!this.dnsServer) {
114
239
  return;
115
240
  }
@@ -118,10 +243,29 @@ export class DnsManager {
118
243
  return;
119
244
  }
120
245
  const zoneName = domain.name.toLowerCase();
121
- // Static dnsScopes zones already get apex NS records from DnsServerRuntime.
122
- if (this.options.dnsScopes?.includes(zoneName)) {
246
+ // Already serving this zone's generated apex NS — do not stack duplicate handlers.
247
+ if (this.runtimeRegistrations.get(domain.id)?.get(this.rrsetKey(zoneName, 'NS'))?.origin
248
+ === 'generated-default') {
123
249
  return;
124
250
  }
251
+
252
+ const ownership = ownershipZonesArg
253
+ ? resolveDomainOwnership({
254
+ fqdn: zoneName,
255
+ zones: ownershipZonesArg,
256
+ authorityZones: this.getAuthorityZones(),
257
+ })
258
+ : await this.resolveDomainOwnership(zoneName);
259
+ if (!ownership.verified) {
260
+ logger.log(
261
+ 'error',
262
+ `DnsManager: refusing to serve authoritative apex NS records for ${zoneName} — ${ownership.detail}. `
263
+ + 'dcrouter answers with the aa flag for every handler it registers, so this zone stays unserved (REFUSED) until ownership is verified.',
264
+ { zone: 'dns', domainId: domain.id, ownershipReason: ownership.reason },
265
+ );
266
+ return;
267
+ }
268
+
125
269
  const existingRecords = await DnsRecordDoc.findByDomainId(domain.id);
126
270
  const hasExplicitApexNs = existingRecords.some(
127
271
  (rec) => rec.type === 'NS' && rec.name.toLowerCase() === zoneName,
@@ -143,12 +287,165 @@ export class DnsManager {
143
287
  return null;
144
288
  });
145
289
  }
290
+ this.trackRuntimeRegistration(domain.id, zoneName, 'NS', 'generated-default');
146
291
  logger.log(
147
292
  'info',
148
- `DnsManager: serving default apex NS records for authoritative zone ${zoneName} (${nsDomains.join(', ')})`,
293
+ `DnsManager: serving default apex NS records for authoritative zone ${zoneName} `
294
+ + `(${nsDomains.join(', ')}); ownership verified via ${ownership.method} (${ownership.evidence})`,
149
295
  );
150
296
  }
151
297
 
298
+ // ==========================================================================
299
+ // Domain ownership
300
+ // ==========================================================================
301
+
302
+ /** Ownership inputs for every managed zone, fetched once per evaluation pass. */
303
+ public async listOwnershipZones(): Promise<IDomainOwnershipZone[]> {
304
+ const domains = await DomainDoc.findAll();
305
+ return domains.map((domainArg) => ({
306
+ name: domainArg.name,
307
+ source: domainArg.source,
308
+ providerId: domainArg.providerId,
309
+ }));
310
+ }
311
+
312
+ /**
313
+ * The authority set used as an ownership proof: the delegation-verified zones
314
+ * held in the database, and nothing else. Empty until the resolver is wired,
315
+ * which fails closed — an unwired manager proves ownership of nothing rather
316
+ * than falling back to a declared list that no longer exists.
317
+ */
318
+ public getAuthorityZones(): string[] {
319
+ return this.authorityZonesResolver?.() || [];
320
+ }
321
+
322
+ /**
323
+ * Supply the effective authority set. Injected rather than imported so
324
+ * DnsManager keeps a single direction of dependency and stays unit-testable.
325
+ */
326
+ public setAuthorityZonesResolver(resolver?: () => string[]): void {
327
+ this.authorityZonesResolver = resolver;
328
+ }
329
+
330
+ /**
331
+ * Re-derive every generated apex NS registration against the current authority
332
+ * set. Zones that gained proof start being served; zones that lost it are torn
333
+ * down — both in-process, with no restart.
334
+ *
335
+ * Throws on the first failure so the caller can roll the change back rather
336
+ * than leave the router half-converted.
337
+ */
338
+ public async reconcileAuthoritativeZones(): Promise<{ registered: string[]; unregistered: string[] }> {
339
+ const registered: string[] = [];
340
+ const unregistered: string[] = [];
341
+ if (!this.dnsServer) {
342
+ return { registered, unregistered };
343
+ }
344
+ const allDomains = await DomainDoc.findAll();
345
+ const ownershipZones: IDomainOwnershipZone[] = allDomains.map((domainArg) => ({
346
+ name: domainArg.name,
347
+ source: domainArg.source,
348
+ providerId: domainArg.providerId,
349
+ }));
350
+
351
+ for (const domain of allDomains) {
352
+ if (domain.source !== 'dcrouter') continue;
353
+ const zoneName = domain.name.toLowerCase();
354
+ const ownership = resolveDomainOwnership({
355
+ fqdn: zoneName,
356
+ zones: ownershipZones,
357
+ authorityZones: this.getAuthorityZones(),
358
+ });
359
+ const hasGenerated = this.runtimeRegistrations
360
+ .get(domain.id)?.get(this.rrsetKey(zoneName, 'NS'))?.origin === 'generated-default';
361
+
362
+ if (!ownership.verified && hasGenerated) {
363
+ // Authority lost: stop answering for it immediately.
364
+ this.untrackRuntimeRegistration(domain.id, zoneName, 'NS');
365
+ this.dnsServer.unregisterHandler(zoneName, ['NS']);
366
+ unregistered.push(zoneName);
367
+ continue;
368
+ }
369
+ if (ownership.verified && !hasGenerated) {
370
+ await this.registerAuthoritativeZoneDefaults(domain, ownershipZones);
371
+ if (this.runtimeRegistrations
372
+ .get(domain.id)?.get(this.rrsetKey(zoneName, 'NS'))?.origin === 'generated-default') {
373
+ registered.push(zoneName);
374
+ }
375
+ }
376
+ }
377
+
378
+ // Persisted records of newly-authoritative zones are already registered by
379
+ // applyDcrouterDomainsToDnsServer(); only the generated defaults are gated on
380
+ // ownership, so nothing else needs rebuilding here.
381
+ if (registered.length || unregistered.length) {
382
+ logger.log(
383
+ 'info',
384
+ `DnsManager: authority reconciliation registered ${registered.length} and unregistered ${unregistered.length} generated apex NS zone(s)`,
385
+ { zone: 'dns' },
386
+ );
387
+ }
388
+ return { registered, unregistered };
389
+ }
390
+
391
+ /** Keep `DomainDoc.authoritative` honest after an authority change. */
392
+ public async syncAuthoritativeFlags(): Promise<number> {
393
+ const allDomains = await DomainDoc.findAll();
394
+ const ownershipZones: IDomainOwnershipZone[] = allDomains.map((domainArg) => ({
395
+ name: domainArg.name,
396
+ source: domainArg.source,
397
+ providerId: domainArg.providerId,
398
+ }));
399
+ const authorityZones = this.getAuthorityZones();
400
+ let changed = 0;
401
+ for (const domain of allDomains) {
402
+ if (domain.source !== 'dcrouter') continue;
403
+ const ownership = resolveDomainOwnership({
404
+ fqdn: domain.name,
405
+ zones: ownershipZones,
406
+ authorityZones,
407
+ });
408
+ if (domain.authoritative === ownership.verified) continue;
409
+ domain.authoritative = ownership.verified;
410
+ domain.updatedAt = Date.now();
411
+ await domain.save();
412
+ changed++;
413
+ }
414
+ return changed;
415
+ }
416
+
417
+ /**
418
+ * Can we prove the zone containing `fqdn` is ours? This gates certificate
419
+ * requirements and authoritative DNS. See ts/dns/domain-ownership.ts.
420
+ */
421
+ public async resolveDomainOwnership(fqdn: string): Promise<TDomainOwnership> {
422
+ return resolveDomainOwnership({
423
+ fqdn,
424
+ zones: await this.listOwnershipZones(),
425
+ authorityZones: this.getAuthorityZones(),
426
+ });
427
+ }
428
+
429
+ /**
430
+ * Ownership of a zone that is about to become dcrouter-hosted, evaluated
431
+ * against its post-write shape. Create and provider→dcrouter migration must
432
+ * not read their own pre-write row: the provider link they are removing would
433
+ * otherwise still count as the proof.
434
+ */
435
+ private async resolveOwnershipForPendingDcrouterZone(
436
+ zoneName: string,
437
+ ): Promise<TDomainOwnership> {
438
+ const name = zoneName.trim().toLowerCase();
439
+ const zones: IDomainOwnershipZone[] = (await this.listOwnershipZones())
440
+ .filter((zoneArg) => zoneArg.name.trim().toLowerCase() !== name);
441
+ zones.push({ name, source: 'dcrouter' });
442
+ return resolveDomainOwnership({
443
+ fqdn: name,
444
+ zones,
445
+ authorityZones: this.getAuthorityZones(),
446
+ });
447
+ }
448
+
152
449
  /**
153
450
  * Register a single record with the embedded DnsServer. The handler closure
154
451
  * captures the record fields, so updates require a re-register cycle.
@@ -167,13 +464,22 @@ export class DnsManager {
167
464
  }
168
465
  return null;
169
466
  });
467
+ // Tracked here rather than at each call site so the direct registration in
468
+ // migrateToDcrouter() is covered as well.
469
+ this.trackRuntimeRegistration(rec.domainId, rec.name, rec.type, 'persisted');
170
470
  }
171
471
 
172
472
  private rrsetKey(name: string, type: TDnsRecordType): string {
173
473
  return `${name.toLowerCase()}|${type}`;
174
474
  }
175
475
 
176
- /** Rebuild one authoritative RRset atomically from persisted rows. */
476
+ /**
477
+ * Rebuild one authoritative RRset atomically from persisted rows.
478
+ *
479
+ * The unregister-then-rebuild cycle also replaces a generated default on the
480
+ * same key (an explicit apex NS row supersedes the generated one), so the
481
+ * registry is updated to match what is actually registered afterwards.
482
+ */
177
483
  private async refreshLocalRrset(
178
484
  domainId: string,
179
485
  name: string,
@@ -181,6 +487,7 @@ export class DnsManager {
181
487
  ): Promise<void> {
182
488
  if (!this.dnsServer) return;
183
489
  this.dnsServer.unregisterHandler(name, [type]);
490
+ this.untrackRuntimeRegistration(domainId, name, type);
184
491
  const records = await DnsRecordDoc.findByDomainId(domainId);
185
492
  for (const record of records) {
186
493
  if (record.name.toLowerCase() === name.toLowerCase() && record.type === type) {
@@ -511,8 +818,14 @@ export class DnsManager {
511
818
  }
512
819
 
513
820
  /**
514
- * Create a dcrouter-hosted (authoritative) domain. dcrouter will serve
515
- * DNS records for this domain via the embedded smartdns.DnsServer.
821
+ * Create a dcrouter-hosted domain. dcrouter serves DNS records for it via the
822
+ * embedded smartdns.DnsServer.
823
+ *
824
+ * `authoritative` reflects whether ownership can actually be proven — it used
825
+ * to be hard-coded to `true`, which let an ops-API caller self-assert authority
826
+ * over any zone on the internet. Creating the record still succeeds (it is the
827
+ * container records and provider migration need), but an unverified zone is
828
+ * recorded as non-authoritative and gets no generated apex NS handler.
516
829
  */
517
830
  public async createDcrouterDomain(args: {
518
831
  name: string;
@@ -527,17 +840,25 @@ export class DnsManager {
527
840
  if (existing) {
528
841
  throw new Error(`domain ${name} already exists`);
529
842
  }
843
+ const ownership = await this.resolveOwnershipForPendingDcrouterZone(name);
530
844
  const now = Date.now();
531
845
  const doc = new DomainDoc();
532
846
  doc.id = plugins.uuid.v4();
533
847
  doc.name = name;
534
848
  doc.source = 'dcrouter';
535
- doc.authoritative = true;
849
+ doc.authoritative = ownership.verified;
536
850
  doc.description = args.description;
537
851
  doc.createdAt = now;
538
852
  doc.updatedAt = now;
539
853
  doc.createdBy = args.createdBy;
540
854
  await doc.save();
855
+ if (!ownership.verified) {
856
+ logger.log(
857
+ 'error',
858
+ `DnsManager: created dcrouter-hosted domain ${name} as NON-authoritative — ${ownership.detail}`,
859
+ { zone: 'dns', domainId: doc.id, ownershipReason: ownership.reason },
860
+ );
861
+ }
541
862
  await this.registerAuthoritativeZoneDefaults(doc);
542
863
  return doc.id;
543
864
  }
@@ -621,26 +942,33 @@ export class DnsManager {
621
942
  * For dcrouter-hosted domains, also unregisters records from the embedded
622
943
  * DnsServer.
623
944
  *
624
- * Note: smartdns has no public unregister-by-name API in the version pinned
625
- * here, so local record deletes only take effect after a restart. The DB
626
- * is the source of truth and the next start will not register the deleted
627
- * record.
945
+ * The unregister path is complete in-process: every runtime handler this
946
+ * manager registered for the domain — persisted RRsets and generated defaults
947
+ * alike — is removed from the registry, so deletion never leaves stale
948
+ * authority answering `aa` until an unrelated restart. Nameserver glue
949
+ * handlers and other domains' handlers are never touched, because only the
950
+ * domain's own recorded registrations are removed.
628
951
  */
629
952
  public async deleteDomain(id: string): Promise<boolean> {
630
953
  const doc = await DomainDoc.findById(id);
631
954
  if (!doc) return false;
955
+ // Tear runtime state down before the durable rows go away: the registry is
956
+ // the authority on what to remove, so this does not depend on the records
957
+ // still being readable.
958
+ this.tearDownDomainRuntimeRegistrations(id);
632
959
  const records = await DnsRecordDoc.findByDomainId(id);
633
- const localRrsets = new Set(records.map((record) => this.rrsetKey(record.name, record.type)));
634
960
  for (const r of records) {
635
961
  await r.delete();
636
962
  }
637
- if (doc.source === 'dcrouter' && this.dnsServer) {
638
- for (const rrset of localRrsets) {
639
- const [name, type] = rrset.split('|') as [string, TDnsRecordType];
640
- this.dnsServer.unregisterHandler(name, [type]);
641
- }
642
- }
643
963
  await doc.delete();
964
+ const leftover = this.listRuntimeRegistrations(id);
965
+ if (leftover.length > 0) {
966
+ logger.log(
967
+ 'error',
968
+ `DnsManager: ${leftover.length} runtime DNS handler key(s) are still registered for deleted domain ${doc.name}`,
969
+ { zone: 'dns', domainId: id },
970
+ );
971
+ }
644
972
  return true;
645
973
  }
646
974
 
@@ -843,6 +1171,13 @@ export class DnsManager {
843
1171
  }
844
1172
  }
845
1173
 
1174
+ // The provider is authoritative from here on, so every runtime handler we
1175
+ // still own for this zone goes away — including the generated apex NS and any
1176
+ // record whose individual migration above threw. Leaving them registered
1177
+ // would keep answering `aa` for a zone we no longer serve: the same
1178
+ // stale-authority leak that deletion had.
1179
+ this.tearDownDomainRuntimeRegistrations(domain.id);
1180
+
846
1181
  // Update domain metadata
847
1182
  domain.source = 'provider';
848
1183
  domain.authoritative = false;
@@ -879,15 +1214,25 @@ export class DnsManager {
879
1214
  }
880
1215
  }
881
1216
 
882
- // Update domain metadata
1217
+ // Update domain metadata. Losing the provider link means the credentialed
1218
+ // zone listing no longer proves ownership, so authority is re-derived rather
1219
+ // than assumed.
883
1220
  domain.source = 'dcrouter';
884
- domain.authoritative = true;
885
1221
  domain.providerId = undefined;
886
1222
  domain.externalZoneId = undefined;
887
1223
  domain.nameservers = undefined;
888
1224
  domain.lastSyncedAt = undefined;
889
1225
  domain.updatedAt = Date.now();
1226
+ const ownership = await this.resolveOwnershipForPendingDcrouterZone(domain.name);
1227
+ domain.authoritative = ownership.verified;
890
1228
  await domain.save();
1229
+ if (!ownership.verified) {
1230
+ logger.log(
1231
+ 'error',
1232
+ `DnsManager: domain ${domain.name} migrated to dcrouter as NON-authoritative — ${ownership.detail}`,
1233
+ { zone: 'dns', domainId: domain.id, ownershipReason: ownership.reason },
1234
+ );
1235
+ }
891
1236
  await this.registerAuthoritativeZoneDefaults(domain);
892
1237
 
893
1238
  logger.log('info', `Domain ${domain.name} migrated to dcrouter (${migrated} records)`);
@@ -1077,6 +1422,7 @@ export class DnsManager {
1077
1422
  * Unregister a record's handler from the embedded DnsServer.
1078
1423
  */
1079
1424
  public unregisterRecordFromDnsServer(rec: DnsRecordDoc): void {
1425
+ this.untrackRuntimeRegistration(rec.domainId, rec.name, rec.type);
1080
1426
  if (!this.dnsServer) return;
1081
1427
  this.dnsServer.unregisterHandler(rec.name, [rec.type]);
1082
1428
  }
@@ -80,6 +80,10 @@ export const DCR_ROUTING_ERROR = 'DCR_ROUTING_ERROR';
80
80
  export const DCR_CONFIGURATION_ERROR = 'DCR_CONFIGURATION_ERROR';
81
81
  export const DCR_PROXY_ERROR = 'DCR_PROXY_ERROR';
82
82
  export const DCR_DOMAIN_ERROR = 'DCR_DOMAIN_ERROR';
83
+ /** A certificate requirement or authoritative DNS zone was requested for a domain we cannot prove we own. */
84
+ export const DCR_DOMAIN_OWNERSHIP_UNVERIFIED = 'DCR_DOMAIN_OWNERSHIP_UNVERIFIED';
85
+ /** ACME failed for a configuration cause that no amount of retrying can resolve. */
86
+ export const DCR_ACME_PERMANENT_FAILURE = 'DCR_ACME_PERMANENT_FAILURE';
83
87
 
84
88
  // SMS service errors (SMS_*)
85
89
  export const SMS_SERVICE_ERROR = 'SMS_SERVICE_ERROR';
@@ -37,6 +37,7 @@ export class OpsServer {
37
37
  private usersHandler!: handlers.UsersHandler;
38
38
  private dnsProviderHandler!: handlers.DnsProviderHandler;
39
39
  private domainHandler!: handlers.DomainHandler;
40
+ private dnsAuthorityHandler!: handlers.DnsAuthorityHandler;
40
41
  private dnsRecordHandler!: handlers.DnsRecordHandler;
41
42
  private acmeConfigHandler!: handlers.AcmeConfigHandler;
42
43
  private emailDomainHandler!: handlers.EmailDomainHandler;
@@ -104,6 +105,7 @@ export class OpsServer {
104
105
  this.usersHandler = new handlers.UsersHandler(this);
105
106
  this.dnsProviderHandler = new handlers.DnsProviderHandler(this);
106
107
  this.domainHandler = new handlers.DomainHandler(this);
108
+ this.dnsAuthorityHandler = new handlers.DnsAuthorityHandler(this);
107
109
  this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
108
110
  this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
109
111
  this.emailDomainHandler = new handlers.EmailDomainHandler(this);
@@ -66,6 +66,13 @@ export class AcmeConfigHandler {
66
66
  },
67
67
  userId,
68
68
  );
69
+ // The SmartAcme startup budget does not re-arm on a timer, so a
70
+ // corrected ACME configuration must re-arm it explicitly. Otherwise a
71
+ // permanently-failed or exhausted provider stays down until an
72
+ // unrelated SmartProxy rebuild or a restart (30–60 s of outage).
73
+ this.opsServerRef.dcRouterRef.smartAcmeLifecycle?.rearm(
74
+ 'ACME configuration updated',
75
+ );
69
76
  return { success: true, config: updated };
70
77
  } catch (err: unknown) {
71
78
  return { success: false, message: (err as Error).message };