@serve.zone/dcrouter 18.7.1 → 18.8.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.
@@ -10,16 +10,7 @@ import {
10
10
 
11
11
  type TDnsRecordSeed = { name: string; type: string; value: string; ttl?: number; useIngressProxy?: boolean };
12
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
- */
13
+ /** The required DNSSEC keying zone when dcrouter currently claims no DNS zone. */
23
14
  export const NO_AUTHORITY_SENTINEL_ZONE = 'no-authority.invalid';
24
15
 
25
16
  /**
@@ -38,28 +29,12 @@ export class DnsServerRuntime {
38
29
  private privateRouteHostnames = new Set<string>();
39
30
  private privateRouteTargetIp?: string;
40
31
 
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
- */
32
+ /** Local snapshot of the zone set successfully applied to smartdns. */
62
33
  private readonly authoritativeZones: string[] = [];
34
+ private attachedDnsServer?: plugins.smartdns.dnsServerMod.DnsServer;
35
+ private cleanupPendingDnsServer?: plugins.smartdns.dnsServerMod.DnsServer;
36
+ private cleanupPendingDnsServerPromise?: Promise<void>;
37
+ private lifecycleTail: Promise<void> = Promise.resolve();
63
38
 
64
39
  constructor(private dcRouterRef: DcRouter) {}
65
40
 
@@ -72,7 +47,15 @@ export class DnsServerRuntime {
72
47
  * Create the DNS server, start it on UDP, wire metrics/logging, and
73
48
  * register all generated records.
74
49
  */
75
- public async setup(): Promise<void> {
50
+ public setup(): Promise<void> {
51
+ return this.enqueueLifecycle(() => this.setupInternal());
52
+ }
53
+
54
+ private async setupInternal(): Promise<void> {
55
+ await this.retryPendingDnsServerCleanup();
56
+ if (this.dcRouterRef.dnsServer) {
57
+ throw new Error('DNS server setup refused because another server instance is still owned');
58
+ }
76
59
  const options = this.dcRouterRef.options;
77
60
  if (!options.dnsNsDomains || options.dnsNsDomains.length === 0) {
78
61
  throw new Error('dnsNsDomains is required for DNS server setup');
@@ -154,21 +137,20 @@ export class DnsServerRuntime {
154
137
  // authority set — sorted, therefore stable across restarts rather than
155
138
  // dependent on database insertion order.
156
139
  dnssecZone: bootAuthorityZones[0] || NO_AUTHORITY_SENTINEL_ZONE,
157
- // Live array, mutated in place by syncAuthorityZones(). See the field.
158
- authoritativeZones: this.authoritativeZones,
140
+ // SmartDNS copies this boot snapshot; later changes use its live setter.
141
+ authoritativeZones: bootAuthorityZones,
159
142
  primaryNameserver: primaryNameserver, // Automatically generates correct SOA records
160
143
  // For now, use self-signed cert until we integrate with Let's Encrypt
161
144
  httpsKey: '',
162
145
  httpsCert: ''
163
146
  });
164
- this.assertLiveAuthorityZones(dnsServer);
165
147
  this.dcRouterRef.dnsServer = dnsServer;
166
148
  this.registerPrivateRouteHandler(dnsServer);
167
149
 
168
- // Start the DNS server. smartdns owns UDP and DNS-over-TCP; dcrouter
169
- // keeps DNS-over-HTTPS routed through SmartProxy manual HTTPS mode.
170
- await dnsServer.start();
171
150
  try {
151
+ // SmartDNS owns UDP and DNS-over-TCP; dcrouter keeps DNS-over-HTTPS
152
+ // routed through SmartProxy manual HTTPS mode.
153
+ await dnsServer.start();
172
154
  logger.log('info', `DNS server started on UDP/TCP ${vmIpAddress}:53`);
173
155
 
174
156
  // Wire DNS query events to MetricsManager and logger with adaptive rate limiting
@@ -244,26 +226,125 @@ export class DnsServerRuntime {
244
226
  // dcrouter-hosted domains get registered too.
245
227
  await this.attachDnsServer(dnsServer);
246
228
  } catch (error) {
229
+ this.detachDnsServer(dnsServer);
247
230
  dnsServer.removeAllListeners();
248
- await dnsServer.stop().catch((stopError: Error) => {
231
+ let stopError: Error | undefined;
232
+ try {
233
+ await dnsServer.stop();
234
+ } catch (errorDuringStop: unknown) {
235
+ stopError = errorDuringStop instanceof Error
236
+ ? errorDuringStop
237
+ : new Error(String(errorDuringStop));
238
+ this.cleanupPendingDnsServer = dnsServer;
239
+ void this.ensureDnsServerCleanup(dnsServer, true);
249
240
  logger.log('warn', `Failed to stop DNS server after setup failure: ${stopError.message}`);
250
- });
251
- if (this.dcRouterRef.dnsServer === dnsServer) {
241
+ }
242
+ if (!stopError && this.dcRouterRef.dnsServer === dnsServer) {
252
243
  this.dcRouterRef.dnsServer = undefined;
253
244
  }
254
245
  this.flushQueryLogBatch();
246
+ if (stopError) {
247
+ throw new AggregateError(
248
+ [error, stopError],
249
+ 'DNS server setup failed and process cleanup remains pending',
250
+ );
251
+ }
255
252
  throw error;
256
253
  }
257
254
  }
258
255
 
256
+ private async retryPendingDnsServerCleanup(): Promise<void> {
257
+ const pendingServer = this.cleanupPendingDnsServer;
258
+ if (!pendingServer) return;
259
+ await this.ensureDnsServerCleanup(pendingServer);
260
+ }
261
+
262
+ /**
263
+ * Keep retrying until SmartDNS confirms Rust process closure. Taskbuffer
264
+ * swallows service-stop errors, so returning after a failed termination would
265
+ * otherwise strand the child and its port bindings with no future retry.
266
+ */
267
+ private ensureDnsServerCleanup(
268
+ dnsServerArg: plugins.smartdns.dnsServerMod.DnsServer,
269
+ delayFirstAttemptArg = false,
270
+ ): Promise<void> {
271
+ if (this.cleanupPendingDnsServerPromise) {
272
+ if (this.cleanupPendingDnsServer !== dnsServerArg) {
273
+ throw new Error('Cannot clean up two DNS server instances concurrently');
274
+ }
275
+ return this.cleanupPendingDnsServerPromise;
276
+ }
277
+
278
+ this.cleanupPendingDnsServer = dnsServerArg;
279
+ let cleanupPromise: Promise<void>;
280
+ cleanupPromise = this.cleanupDnsServerUntilStopped(dnsServerArg, delayFirstAttemptArg)
281
+ .finally(() => {
282
+ if (this.cleanupPendingDnsServerPromise === cleanupPromise) {
283
+ this.cleanupPendingDnsServerPromise = undefined;
284
+ }
285
+ });
286
+ this.cleanupPendingDnsServerPromise = cleanupPromise;
287
+ return cleanupPromise;
288
+ }
289
+
290
+ private async cleanupDnsServerUntilStopped(
291
+ dnsServerArg: plugins.smartdns.dnsServerMod.DnsServer,
292
+ delayFirstAttemptArg: boolean,
293
+ ): Promise<void> {
294
+ let failedAttempts = 0;
295
+ if (delayFirstAttemptArg) {
296
+ await this.waitForDnsServerCleanupRetry(1);
297
+ }
298
+
299
+ while (this.cleanupPendingDnsServer === dnsServerArg) {
300
+ try {
301
+ await dnsServerArg.stop();
302
+ if (this.dcRouterRef.dnsServer === dnsServerArg) {
303
+ this.dcRouterRef.dnsServer = undefined;
304
+ }
305
+ if (this.cleanupPendingDnsServer === dnsServerArg) {
306
+ this.cleanupPendingDnsServer = undefined;
307
+ }
308
+ return;
309
+ } catch (error: unknown) {
310
+ failedAttempts++;
311
+ const message = error instanceof Error ? error.message : String(error);
312
+ logger.log(
313
+ 'warn',
314
+ `DNS server process cleanup attempt ${failedAttempts} failed; retrying until closure is confirmed: ${message}`,
315
+ );
316
+ await this.waitForDnsServerCleanupRetry(failedAttempts);
317
+ }
318
+ }
319
+ }
320
+
321
+ private async waitForDnsServerCleanupRetry(failedAttemptsArg: number): Promise<void> {
322
+ const delayMs = Math.min(250 * (2 ** Math.min(failedAttemptsArg - 1, 5)), 5000);
323
+ await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
324
+ }
325
+
326
+ private enqueueLifecycle(operationArg: () => Promise<void>): Promise<void> {
327
+ const operation = this.lifecycleTail.then(operationArg, operationArg);
328
+ this.lifecycleTail = operation.then(() => undefined, () => undefined);
329
+ return operation;
330
+ }
331
+
259
332
  private async attachDnsServer(
260
333
  dnsServer: plugins.smartdns.dnsServerMod.DnsServer,
261
334
  ): Promise<void> {
262
335
  if (!this.dcRouterRef.dnsManager) return;
263
336
  await this.dcRouterRef.dnsManager.attachDnsServer(dnsServer);
337
+ this.attachedDnsServer = dnsServer;
264
338
  this.dcRouterRef.mailDnsSync?.requestSync('DNS server attached');
265
339
  }
266
340
 
341
+ private detachDnsServer(dnsServerArg: plugins.smartdns.dnsServerMod.DnsServer): void {
342
+ this.dcRouterRef.dnsManager?.detachDnsServer(dnsServerArg);
343
+ if (this.attachedDnsServer === dnsServerArg) {
344
+ this.attachedDnsServer = undefined;
345
+ }
346
+ }
347
+
267
348
  /**
268
349
  * Re-derive the running server's authoritative zone set from the database.
269
350
  *
@@ -286,42 +367,18 @@ export class DnsServerRuntime {
286
367
  const nextZones = this.effectiveAuthorityZones();
287
368
  const changed = nextZones.length !== this.authoritativeZones.length
288
369
  || 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
- );
370
+ if (!changed) {
371
+ return nextZones;
297
372
  }
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;
373
+ // Commit the local snapshot only after the live server accepts the update.
374
+ this.dcRouterRef.dnsServer?.setAuthoritativeZones(nextZones);
375
+ this.authoritativeZones.splice(0, this.authoritativeZones.length, ...nextZones);
318
376
  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.',
377
+ 'info',
378
+ `DNS authoritative zone set is now [${nextZones.join(', ') || 'empty'}] (${reasonArg})`,
323
379
  { zone: 'dns' },
324
380
  );
381
+ return nextZones;
325
382
  }
326
383
 
327
384
  /**
@@ -329,12 +386,10 @@ export class DnsServerRuntime {
329
386
  * compiled source policy is private or whose only ingress is SmartVPN.
330
387
  * Public routes compile without clientIp restrictions and are never added.
331
388
  *
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.
389
+ * Every hostname must clear the domain-ownership gate first. The handler uses
390
+ * SmartDNS' explicit non-authoritative mode, but the server still listens on a
391
+ * public UDP/TCP 53. Ungated, a private-route definition could publicly hand
392
+ * out an RFC1918 address for a domain whose real delegation belonged elsewhere.
338
393
  */
339
394
  public async syncPrivateRouteOverrides(routesArg: IDcRouterRouteConfig[]): Promise<void> {
340
395
  const candidateHostnames = new Set<string>();
@@ -425,23 +480,7 @@ export class DnsServerRuntime {
425
480
  return ownedHostnames;
426
481
  }
427
482
 
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
- */
483
+ /** Register the split-horizon overlay without claiming DNS authority. */
445
484
  private registerPrivateRouteHandler(
446
485
  dnsServerArg: plugins.smartdns.dnsServerMod.DnsServer,
447
486
  ): void {
@@ -462,7 +501,7 @@ export class DnsServerRuntime {
462
501
  ttl: 60,
463
502
  data: this.privateRouteTargetIp,
464
503
  };
465
- });
504
+ }, { authority: 'non-authoritative', owner: 'private-route-overlay' });
466
505
  }
467
506
 
468
507
  private normalizePrivateRouteHostname(hostnameArg: string): string | undefined {
@@ -475,9 +514,7 @@ export class DnsServerRuntime {
475
514
  return hostname;
476
515
  }
477
516
 
478
- /**
479
- * Create the DoH socket handler SmartProxy routes hand TLS sockets to.
480
- */
517
+ /** Create the DoH handler for cleartext sockets after SmartProxy terminates TLS. */
481
518
  public createSocketHandler(): (socket: plugins.net.Socket) => Promise<void> {
482
519
  return async (socket: plugins.net.Socket) => {
483
520
  if (!this.dcRouterRef.dnsServer) {
@@ -497,9 +534,7 @@ export class DnsServerRuntime {
497
534
  logger.log('debug', 'DNS socket handler: passing socket to DnsServer');
498
535
 
499
536
  try {
500
- // Use the built-in socket handler from smartdns
501
- // This handles HTTP/2, DoH protocol, etc.
502
- await (this.dcRouterRef.dnsServer as any).handleHttpsSocket(socket);
537
+ this.dcRouterRef.dnsServer.handleHttpSocket(socket);
503
538
  } catch (error: unknown) {
504
539
  logger.log('error', `DNS socket handler error: ${(error as Error).message}`);
505
540
  if (!socket.destroyed) {
@@ -523,9 +558,23 @@ export class DnsServerRuntime {
523
558
  }
524
559
  }
525
560
 
526
- /** Stop runtime-owned bookkeeping. smartdns owns network listener cleanup. */
527
- public async stop(): Promise<void> {
561
+ /** Detach handlers and retain termination ownership until process closure. */
562
+ public stop(): Promise<void> {
563
+ return this.enqueueLifecycle(() => this.stopInternal());
564
+ }
565
+
566
+ private async stopInternal(): Promise<void> {
528
567
  this.flushQueryLogBatch();
568
+ const ownedServers = new Set<plugins.smartdns.dnsServerMod.DnsServer>();
569
+ if (this.attachedDnsServer) ownedServers.add(this.attachedDnsServer);
570
+ if (this.cleanupPendingDnsServer) ownedServers.add(this.cleanupPendingDnsServer);
571
+ if (this.dcRouterRef.dnsServer) ownedServers.add(this.dcRouterRef.dnsServer);
572
+
573
+ for (const dnsServer of ownedServers) {
574
+ this.detachDnsServer(dnsServer);
575
+ dnsServer.removeAllListeners();
576
+ await this.ensureDnsServerCleanup(dnsServer);
577
+ }
529
578
  }
530
579
 
531
580
  private registerRecords(records: TDnsRecordSeed[]): void {
@@ -20,11 +20,10 @@ import type { TDomainSource } from '../../ts_interfaces/data/domain.js';
20
20
  * provisioning budget was consumed against a cause no retry can fix and the
21
21
  * certificates silently expired.
22
22
  * - `DomainDoc`s created through the ops API set `authoritative = true`
23
- * unconditionally, and the embedded smartdns server marks *any* answer a
24
- * registered handler produces as authoritative (`aa`) regardless of
25
- * `authoritativeZones`. dcrouter therefore served apex NS records and an
26
- * RFC1918 A record, publicly, for zones whose real delegation belonged to
27
- * third parties.
23
+ * unconditionally, and older embedded SmartDNS releases treated a handler
24
+ * answer as authoritative regardless of configured zones. dcrouter therefore
25
+ * served apex NS records and an RFC1918 A record publicly for zones whose
26
+ * real delegation belonged to third parties.
28
27
  *
29
28
  * There are exactly two proofs available in-process, neither of which an ops-API
30
29
  * caller can forge: