@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
@@ -3,9 +3,25 @@ import { logger } from '../logger.js';
3
3
  import { buildEmailDnsRecords } from '../email/index.js';
4
4
  import type { DcRouter } from '../classes.dcrouter.js';
5
5
  import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
6
+ import {
7
+ resolveDomainOwnership,
8
+ type IDomainOwnershipZone,
9
+ } from './domain-ownership.js';
6
10
 
7
11
  type TDnsRecordSeed = { name: string; type: string; value: string; ttl?: number; useIngressProxy?: boolean };
8
12
 
13
+ /**
14
+ * The zone smartdns is handed when the authority set is empty.
15
+ *
16
+ * smartdns falls back to `[dnssecZone]` whenever `authoritativeZones` is empty,
17
+ * so this value decides what an *unauthoritative* dcrouter would claim. It has
18
+ * to be a name that can never match a real question, or a database that has not
19
+ * been seeded yet would quietly start asserting authority over whatever
20
+ * placeholder we picked. `.invalid` is reserved by RFC 2606 and is guaranteed
21
+ * never to be delegated to anybody.
22
+ */
23
+ export const NO_AUTHORITY_SENTINEL_ZONE = 'no-authority.invalid';
24
+
9
25
  /**
10
26
  * Sets up and feeds the embedded authoritative smartdns server: validates the
11
27
  * DNS configuration, generates authoritative/email/DKIM records, applies
@@ -21,8 +37,37 @@ export class DnsServerRuntime {
21
37
 
22
38
  private privateRouteHostnames = new Set<string>();
23
39
  private privateRouteTargetIp?: string;
40
+
41
+ /**
42
+ * The array instance handed to smartdns as `authoritativeZones`.
43
+ *
44
+ * smartdns keeps the options object by reference and re-reads this array on
45
+ * every query, so mutating it in place changes the authority set of a
46
+ * *running* server.
47
+ *
48
+ * **This is a consumer-side shim, and it is sequenced for replacement.**
49
+ * `authoritativeZones` is a public option, but "the array is retained by
50
+ * reference and re-read per query" is storage behaviour, not a documented
51
+ * contract. The proper fix is a `setAuthoritativeZones()` on smartdns' own
52
+ * `DnsServer`; it cannot be written here because that release does not exist
53
+ * yet, and the only alternative available today — re-creating the server to
54
+ * change its zones — drops UDP/TCP 53, which is the 30–60 s public outage
55
+ * this entire path exists to remove.
56
+ *
57
+ * Guarded two ways until the setter ships: `assertLiveAuthorityZones()` logs
58
+ * at `error` if the reference is no longer shared after construction, and a
59
+ * test asserts that identity against the installed smartdns so a release that
60
+ * starts copying the array fails the suite rather than production.
61
+ */
62
+ private readonly authoritativeZones: string[] = [];
63
+
24
64
  constructor(private dcRouterRef: DcRouter) {}
25
65
 
66
+ /** The authority set, from the database and nowhere else. */
67
+ private effectiveAuthorityZones(): string[] {
68
+ return this.dcRouterRef.dnsAuthorityManager?.getEffectiveZoneNames() || [];
69
+ }
70
+
26
71
  /**
27
72
  * Create the DNS server, start it on UDP, wire metrics/logging, and
28
73
  * register all generated records.
@@ -33,8 +78,41 @@ export class DnsServerRuntime {
33
78
  throw new Error('dnsNsDomains is required for DNS server setup');
34
79
  }
35
80
 
36
- if (!options.dnsScopes || options.dnsScopes.length === 0) {
37
- throw new Error('dnsScopes is required for DNS server setup');
81
+ // Refuse to start when the authority set is *unknown*.
82
+ //
83
+ // This guard is load-bearing and cannot be delegated to the service graph.
84
+ // `DnsServer.dependsOn('DnsManager')` only orders startup levels — taskbuffer
85
+ // starts later levels even when an earlier optional service failed — so
86
+ // without this check an unreadable authority document would leave
87
+ // `dnsAuthorityManager` undefined, `effectiveAuthorityZones()` would return
88
+ // `[]`, and the server would come up REFUSING every query. That is exactly
89
+ // the collapse of "unknown" into "claim nothing" that the three-state model
90
+ // exists to prevent, and it would be indistinguishable from a legitimately
91
+ // empty database.
92
+ const authorityManager = this.dcRouterRef.dnsAuthorityManager;
93
+ if (!authorityManager || authorityManager.getState() === 'unavailable') {
94
+ throw new Error(
95
+ 'DNS authority is unknown (the authority manager is '
96
+ + `${authorityManager ? 'unavailable' : 'not initialized'}), so the DNS server will not start. `
97
+ + 'dcrouter serves DNS authority from the database alone; answering with an empty authority set would '
98
+ + 'assert "we are authoritative for nothing", which is a claim an unreadable database cannot back. '
99
+ + 'Restore database access and restart.',
100
+ );
101
+ }
102
+
103
+ // A *known*-empty set is different, and must not stop the server: DoH
104
+ // routes, the private-route overlay and, above all, the ability to pick up
105
+ // a zone the moment it is verified all depend on the server being up.
106
+ // Fail closed on *authority*, never silently on *service*.
107
+ const bootAuthorityZones = this.syncAuthorityZones('DNS server setup');
108
+ if (bootAuthorityZones.length === 0) {
109
+ logger.log(
110
+ 'error',
111
+ 'DNS server is starting with an empty authority set: no zone has been delegation-verified, so every '
112
+ + 'query will be REFUSED until one is. This is what an unseeded database looks like — read the startup '
113
+ + 'delegation drift audit for the list of zones delegated to us that we are refusing to serve.',
114
+ { zone: 'dns' },
115
+ );
38
116
  }
39
117
 
40
118
  const primaryNameserver = options.dnsNsDomains[0];
@@ -71,13 +149,19 @@ export class DnsServerRuntime {
71
149
  udpBindInterface: vmIpAddress,
72
150
  httpsPort: 443, // Required but won't bind due to manual mode
73
151
  manualHttpsMode: true, // Enable manual HTTPS socket handling
74
- dnssecZone: options.dnsScopes[0],
75
- authoritativeZones: options.dnsScopes,
152
+ // The DNSSEC signing zone is baked into the Rust config at start and
153
+ // cannot be changed without a restart, so it is taken from the boot-time
154
+ // authority set — sorted, therefore stable across restarts rather than
155
+ // dependent on database insertion order.
156
+ dnssecZone: bootAuthorityZones[0] || NO_AUTHORITY_SENTINEL_ZONE,
157
+ // Live array, mutated in place by syncAuthorityZones(). See the field.
158
+ authoritativeZones: this.authoritativeZones,
76
159
  primaryNameserver: primaryNameserver, // Automatically generates correct SOA records
77
160
  // For now, use self-signed cert until we integrate with Let's Encrypt
78
161
  httpsKey: '',
79
162
  httpsCert: ''
80
163
  });
164
+ this.assertLiveAuthorityZones(dnsServer);
81
165
  this.dcRouterRef.dnsServer = dnsServer;
82
166
  this.registerPrivateRouteHandler(dnsServer);
83
167
 
@@ -180,13 +264,80 @@ export class DnsServerRuntime {
180
264
  this.dcRouterRef.mailDnsSync?.requestSync('DNS server attached');
181
265
  }
182
266
 
267
+ /**
268
+ * Re-derive the running server's authoritative zone set from the database.
269
+ *
270
+ * This is the half of authority that registering handlers cannot express.
271
+ * smartdns decides the *response kind* from `authoritativeZones` alone: a name
272
+ * inside a configured zone gets an answer or an authoritative negative, and a
273
+ * name outside every configured zone gets REFUSED — even when a handler is
274
+ * registered and answers other qtypes for that exact name. That asymmetry is
275
+ * the live production defect. `social.io` and `hard.global` are delegated to
276
+ * us and have handlers, so `A` is answered with the `aa` bit, while `AAAA` and
277
+ * `SOA` for the same name are REFUSED, and public recursives turn a REFUSED
278
+ * arm of a dual-stack lookup into SERVFAIL. Verifying a zone therefore has to
279
+ * update this set, not just register handlers, or the zone stays half-served.
280
+ *
281
+ * Idempotent, and safe to call on every authority reconcile.
282
+ *
283
+ * @returns the zone set now in effect.
284
+ */
285
+ public syncAuthorityZones(reasonArg: string): string[] {
286
+ const nextZones = this.effectiveAuthorityZones();
287
+ const changed = nextZones.length !== this.authoritativeZones.length
288
+ || nextZones.some((zone, index) => zone !== this.authoritativeZones[index]);
289
+ // In place: smartdns holds this exact array and re-reads it per query.
290
+ this.authoritativeZones.splice(0, this.authoritativeZones.length, ...nextZones);
291
+ if (changed) {
292
+ logger.log(
293
+ 'info',
294
+ `DNS authoritative zone set is now [${nextZones.join(', ') || 'empty'}] (${reasonArg})`,
295
+ { zone: 'dns' },
296
+ );
297
+ }
298
+ return nextZones;
299
+ }
300
+
301
+ /**
302
+ * Prove smartdns kept our array rather than copying it.
303
+ *
304
+ * If this ever fails, authority silently freezes at its boot-time value and
305
+ * every runtime verification stops taking effect until a restart — a failure
306
+ * that is invisible from the outside because the server keeps answering
307
+ * correctly for the zones it started with. Logged rather than thrown: a frozen
308
+ * authority set is a degradation, and taking DNS down entirely over it would
309
+ * be worse than the fault it reports.
310
+ */
311
+ private assertLiveAuthorityZones(
312
+ dnsServerArg: plugins.smartdns.dnsServerMod.DnsServer,
313
+ ): void {
314
+ const heldZones = (dnsServerArg as unknown as {
315
+ options?: { authoritativeZones?: string[] };
316
+ }).options?.authoritativeZones;
317
+ if (heldZones === this.authoritativeZones) return;
318
+ logger.log(
319
+ 'error',
320
+ 'smartdns did not retain the authoritative zone array by reference, so DNS authority can no longer be '
321
+ + 'reconciled without restarting the DNS server. Zones verified at runtime will register handlers but '
322
+ + 'keep answering REFUSED for every qtype they do not handle until dcrouter is restarted.',
323
+ { zone: 'dns' },
324
+ );
325
+ }
326
+
183
327
  /**
184
328
  * Keep an internal-only A-record overlay for exact route hostnames whose
185
329
  * compiled source policy is private or whose only ingress is SmartVPN.
186
330
  * Public routes compile without clientIp restrictions and are never added.
331
+ *
332
+ * Every hostname must clear the domain-ownership gate first. This overlay is
333
+ * only "internal" by intent, never by mechanism: smartdns marks any answer a
334
+ * registered handler produces authoritative, and the server listens on a public
335
+ * UDP/TCP 53. Ungated, a single route was enough to make dcrouter publicly hand
336
+ * out an RFC1918 address, with the `aa` flag, for a domain whose real
337
+ * delegation belonged to somebody else.
187
338
  */
188
- public syncPrivateRouteOverrides(routesArg: IDcRouterRouteConfig[]): void {
189
- const nextHostnames = new Set<string>();
339
+ public async syncPrivateRouteOverrides(routesArg: IDcRouterRouteConfig[]): Promise<void> {
340
+ const candidateHostnames = new Set<string>();
190
341
  for (const route of routesArg) {
191
342
  if (route.action?.type !== 'forward') continue;
192
343
  const ingress = route.ingress;
@@ -211,10 +362,12 @@ export class DnsServerRuntime {
211
362
  : [];
212
363
  for (const domainArg of domainEntries) {
213
364
  const hostname = this.normalizePrivateRouteHostname(domainArg);
214
- if (hostname) nextHostnames.add(hostname);
365
+ if (hostname) candidateHostnames.add(hostname);
215
366
  }
216
367
  }
217
368
 
369
+ const nextHostnames = await this.filterOwnedPrivateRouteHostnames(candidateHostnames);
370
+
218
371
  const changed = nextHostnames.size !== this.privateRouteHostnames.size
219
372
  || [...nextHostnames].some((hostnameArg) => !this.privateRouteHostnames.has(hostnameArg));
220
373
  this.privateRouteHostnames = nextHostnames;
@@ -227,6 +380,68 @@ export class DnsServerRuntime {
227
380
  }
228
381
  }
229
382
 
383
+ /**
384
+ * Drop overlay hostnames whose zone ownership cannot be proven, logging each
385
+ * rejection at `error` with the reason. Fails closed: if the ownership inputs
386
+ * cannot be read, no hostname is served rather than all of them.
387
+ */
388
+ private async filterOwnedPrivateRouteHostnames(
389
+ candidateHostnames: Set<string>,
390
+ ): Promise<Set<string>> {
391
+ if (candidateHostnames.size === 0) {
392
+ return new Set<string>();
393
+ }
394
+ // The delegation-verified authority set, so a zone verified at runtime
395
+ // starts serving its overlay entries without a restart.
396
+ const authorityZones = this.dcRouterRef.dnsManager?.getAuthorityZones()
397
+ || this.effectiveAuthorityZones();
398
+ let zones: IDomainOwnershipZone[];
399
+ try {
400
+ zones = (await this.dcRouterRef.dnsManager?.listOwnershipZones()) || [];
401
+ } catch (error: unknown) {
402
+ logger.log(
403
+ 'error',
404
+ `DNS private-route overlay: cannot read managed zones (${(error as Error).message}); `
405
+ + `refusing to serve ${candidateHostnames.size} private route hostname(s) rather than answering for unverified domains`,
406
+ { zone: 'dns' },
407
+ );
408
+ return new Set<string>();
409
+ }
410
+
411
+ const ownedHostnames = new Set<string>();
412
+ for (const hostname of candidateHostnames) {
413
+ const ownership = resolveDomainOwnership({ fqdn: hostname, zones, authorityZones });
414
+ if (ownership.verified) {
415
+ ownedHostnames.add(hostname);
416
+ continue;
417
+ }
418
+ logger.log(
419
+ 'error',
420
+ `DNS private-route overlay: refusing to answer for '${hostname}' — ${ownership.detail}. `
421
+ + 'dcrouter answers authoritatively on a public port, so serving this would claim a domain we cannot prove we own.',
422
+ { zone: 'dns', ownershipReason: ownership.reason },
423
+ );
424
+ }
425
+ return ownedHostnames;
426
+ }
427
+
428
+ /**
429
+ * SEQUENCED BEHIND A SMARTDNS RELEASE — this registration must become
430
+ * `{ authority: 'non-authoritative', owner: 'private-route-overlay' }`.
431
+ *
432
+ * The overlay is a split-horizon answer by intent: it hands out an internal
433
+ * address for a hostname whose zone we own, and it deliberately matches `*`
434
+ * so it can cover any owned hostname. Under the hardened smartdns authority
435
+ * model, a handler left at the default `authoritative` mode is *suppressed*
436
+ * for any name outside every configured `authoritativeZones` entry. Overlay
437
+ * hostnames proven by `provider-zone` are exactly that — owned, but not in
438
+ * the delegation-verified set — so on that smartdns they would stop being
439
+ * answered entirely, silently.
440
+ *
441
+ * The options parameter does not exist in the installed smartdns (7.12.1) and
442
+ * the change adding it is unreleased, so this cannot be written yet. It must
443
+ * land in the same change that takes the smartdns bump.
444
+ */
230
445
  private registerPrivateRouteHandler(
231
446
  dnsServerArg: plugins.smartdns.dnsServerMod.DnsServer,
232
447
  ): void {
@@ -370,32 +585,38 @@ export class DnsServerRuntime {
370
585
 
371
586
  private async validateConfiguration(): Promise<void> {
372
587
  const options = this.dcRouterRef.options;
373
- if (!options.dnsNsDomains || !options.dnsScopes) {
588
+ if (!options.dnsNsDomains) {
374
589
  return;
375
590
  }
591
+ const authorityZones = this.effectiveAuthorityZones();
376
592
 
377
593
  logger.log('info', 'Validating DNS configuration...');
594
+ const covered = (nameArg: string): boolean => authorityZones.some((zone) =>
595
+ nameArg === zone || nameArg.endsWith(`.${zone}`));
378
596
 
379
- // Check if email domains with internal-dns are in dnsScopes
597
+ // Email domains served from the embedded DNS need the zone to be ours.
380
598
  if (options.emailConfig?.domains) {
381
599
  for (const domainConfig of options.emailConfig.domains) {
382
- if (domainConfig.dnsMode === 'internal-dns' &&
383
- !options.dnsScopes.includes(domainConfig.domain)) {
384
- logger.log('warn', `Email domain '${domainConfig.domain}' with internal-dns mode is not in dnsScopes. It should be added to dnsScopes.`);
600
+ if (domainConfig.dnsMode === 'internal-dns' && !covered(domainConfig.domain.toLowerCase())) {
601
+ logger.log(
602
+ 'warn',
603
+ `Email domain '${domainConfig.domain}' uses internal-dns but its zone is not delegation-verified, `
604
+ + 'so dcrouter will REFUSE queries for it. Verify the zone (dns-authority:write) to serve it.',
605
+ );
385
606
  }
386
607
  }
387
608
  }
388
609
 
389
- // Validate user-provided DNS records are within scopes
610
+ // Validate caller-provided DNS records fall inside a zone we may answer for
390
611
  if (options.dnsRecords) {
391
612
  for (const record of options.dnsRecords) {
392
- const recordDomain = this.extractDomain(record.name);
393
- const isInScope = options.dnsScopes.some(scope =>
394
- recordDomain === scope || recordDomain.endsWith(`.${scope}`)
395
- );
396
-
397
- if (!isInScope) {
398
- logger.log('warn', `DNS record for '${record.name}' is outside defined scopes [${options.dnsScopes.join(', ')}]`);
613
+ const recordDomain = this.extractDomain(record.name).toLowerCase();
614
+ if (!covered(recordDomain)) {
615
+ logger.log(
616
+ 'warn',
617
+ `DNS record for '${record.name}' is outside every delegation-verified zone `
618
+ + `[${authorityZones.join(', ') || 'none'}] and will be REFUSED`,
619
+ );
399
620
  }
400
621
  }
401
622
  }
@@ -486,11 +707,24 @@ export class DnsServerRuntime {
486
707
  );
487
708
  }
488
709
 
710
+ /**
711
+ * Nameserver A records (glue) only.
712
+ *
713
+ * Generated apex NS records used to be emitted here, one static set per
714
+ * bootstrap `dnsScopes` entry, registered once at setup and never revisited.
715
+ * Under database-sourced authority that is wrong twice over: a zone verified
716
+ * after startup would never get them, and a zone whose authority was revoked
717
+ * would keep them until an unrelated restart. `DnsManager` owns generated apex
718
+ * NS for every authoritative zone now — it already did for every zone that was
719
+ * not in `dnsScopes` — and reconciles them in-process in both directions.
720
+ * A verified zone with no `DomainDoc` behind it therefore serves no apex NS,
721
+ * which the `verified-but-unhosted` drift finding reports at `error`.
722
+ */
489
723
  private async generateAuthoritativeRecords(): Promise<TDnsRecordSeed[]> {
490
724
  const options = this.dcRouterRef.options;
491
725
  const records: TDnsRecordSeed[] = [];
492
726
 
493
- if (!options.dnsNsDomains || !options.dnsScopes) {
727
+ if (!options.dnsNsDomains) {
494
728
  return records;
495
729
  }
496
730
 
@@ -544,23 +778,8 @@ export class DnsServerRuntime {
544
778
  logger.log('info', `Generated A records for ${options.dnsNsDomains.length} nameservers`);
545
779
  }
546
780
 
547
- // Generate NS records for each domain in scopes
548
- for (const domain of options.dnsScopes) {
549
- // Add NS records for all nameservers
550
- for (const nsDomain of options.dnsNsDomains) {
551
- records.push({
552
- name: domain,
553
- type: 'NS',
554
- value: nsDomain,
555
- ttl: 3600
556
- });
557
- }
558
-
559
- // SOA records are now automatically generated by smartdns DnsServer
560
- // with the primaryNameserver configuration option
561
- }
562
-
563
- logger.log('info', `Generated ${records.length} total records (A + NS) for ${options.dnsScopes.length} domains`);
781
+ // Apex NS records are owned by DnsManager (see the doc comment above), and
782
+ // SOA is synthesized by smartdns from `primaryNameserver`.
564
783
  return records;
565
784
  }
566
785
 
@@ -132,8 +132,12 @@ export class GatewayRouteDnsReconciler {
132
132
  const targetIp = optionsArg.delete
133
133
  ? undefined
134
134
  : this.getDesiredTargetIp(optionsArg.gatewayClientId, hostname);
135
- const proxied = optionsArg.proxied ?? false;
136
- const workKey = this.buildWorkKey(optionsArg, hostname, mode, targetIp, proxied);
135
+ // Routes synced before proxy intent was persisted carry no intent at all.
136
+ // Resolving that to a hardcoded default here would delete a live proxied
137
+ // record and recreate it unproxied, so the intent stays undefined until it
138
+ // can be resolved against the current record state under the hostname lock.
139
+ const proxiedIntent = optionsArg.proxied;
140
+ const workKey = this.buildWorkKey(optionsArg, hostname, mode, targetIp, proxiedIntent);
137
141
  const latestIntent = this.latestIntentByHostname.get(hostname);
138
142
  if (latestIntent?.workKey === workKey) return await latestIntent.promise;
139
143
 
@@ -156,7 +160,7 @@ export class GatewayRouteDnsReconciler {
156
160
  mode,
157
161
  checkedAt,
158
162
  targetIp,
159
- proxied,
163
+ proxiedIntent,
160
164
  executionArg.providerZoneRefreshes,
161
165
  );
162
166
  });
@@ -184,7 +188,7 @@ export class GatewayRouteDnsReconciler {
184
188
  mode: Exclude<plugins.servezoneInterfaces.data.TGatewayRouteDnsMode, 'skip'>,
185
189
  checkedAt: number,
186
190
  targetIp: string | undefined,
187
- proxied: boolean,
191
+ proxiedIntent: boolean | undefined,
188
192
  providerZoneRefreshes?: Map<string, Promise<IProviderZoneRefreshResult>>,
189
193
  ): Promise<TGatewayDnsResult> {
190
194
  const claimOwnerId = this.buildClaimOwnerId(optionsArg.gatewayClientId, hostname);
@@ -294,6 +298,11 @@ export class GatewayRouteDnsReconciler {
294
298
  );
295
299
  }
296
300
 
301
+ // Absent proxy intent must never be read as "not proxied": that would make
302
+ // the desired lookup miss a live proxied record and replace it with an
303
+ // unproxied one, exposing the origin. Fall back to the stored record state
304
+ // the same way dnsMode and ingress fall back to their persisted values.
305
+ const proxied = proxiedIntent ?? this.getStoredProxiedState(records, recordType, claimOwnerId);
297
306
  const desired = records.find((recordArg) => recordArg.type === recordType
298
307
  && recordArg.value === targetIp
299
308
  && recordArg.ttl === GATEWAY_ROUTE_DNS_TTL
@@ -620,7 +629,7 @@ export class GatewayRouteDnsReconciler {
620
629
  hostnameArg: string,
621
630
  modeArg: Exclude<plugins.servezoneInterfaces.data.TGatewayRouteDnsMode, 'skip'>,
622
631
  targetIpArg: string | undefined,
623
- proxiedArg: boolean,
632
+ proxiedArg: boolean | undefined,
624
633
  ): string {
625
634
  return JSON.stringify({
626
635
  hostname: hostnameArg,
@@ -629,11 +638,37 @@ export class GatewayRouteDnsReconciler {
629
638
  mode: modeArg,
630
639
  delete: optionsArg.delete === true,
631
640
  targetIp: targetIpArg || null,
632
- proxied: proxiedArg,
641
+ proxied: proxiedArg ?? null,
633
642
  claims: this.getActiveClaimsForHostname(hostnameArg),
634
643
  });
635
644
  }
636
645
 
646
+ /**
647
+ * Recover the effective proxy state for a claim whose route carries no
648
+ * explicit proxy intent. Preferring the reconciler's own record keeps managed
649
+ * proxying stable, and falling back to any address record under the hostname
650
+ * keeps an operator's proxy protection when automation claims the hostname.
651
+ */
652
+ private getStoredProxiedState(
653
+ recordsArg: DnsRecordDoc[],
654
+ recordTypeArg: 'A' | 'AAAA',
655
+ claimOwnerIdArg: string,
656
+ ): boolean {
657
+ const isOwned = (recordArg: DnsRecordDoc) => recordArg.managedBy === GATEWAY_ROUTE_DNS_MANAGED_BY
658
+ && recordArg.managedOwnerId === claimOwnerIdArg;
659
+ const preferenceOrder: Array<(recordArg: DnsRecordDoc) => boolean> = [
660
+ (recordArg) => recordArg.type === recordTypeArg && isOwned(recordArg),
661
+ (recordArg) => isOwned(recordArg),
662
+ (recordArg) => recordArg.type === recordTypeArg,
663
+ () => true,
664
+ ];
665
+ for (const matches of preferenceOrder) {
666
+ const candidate = recordsArg.find((recordArg) => matches(recordArg));
667
+ if (candidate) return candidate.proxied === true;
668
+ }
669
+ return false;
670
+ }
671
+
637
672
  private async refreshProviderZone(
638
673
  zoneIdArg: string,
639
674
  providerZoneRefreshesArg?: Map<string, Promise<IProviderZoneRefreshResult>>,