@serve.zone/dcrouter 17.10.2 → 18.0.1

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 (89) 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/email/classes.accepted-email-spool.d.ts +15 -0
  29. package/dist_ts/email/classes.accepted-email-spool.js +102 -23
  30. package/dist_ts/email/classes.smartmta-blob-storage-manager.js +12 -3
  31. package/dist_ts/errors/error.codes.d.ts +4 -0
  32. package/dist_ts/errors/error.codes.js +5 -1
  33. package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
  34. package/dist_ts/opsserver/classes.opsserver.js +3 -1
  35. package/dist_ts/opsserver/handlers/acme-config.handler.js +6 -1
  36. package/dist_ts/opsserver/handlers/certificate.handler.d.ts +16 -0
  37. package/dist_ts/opsserver/handlers/certificate.handler.js +96 -10
  38. package/dist_ts/opsserver/handlers/config.handler.js +4 -2
  39. package/dist_ts/opsserver/handlers/dns-authority.handler.d.ts +20 -0
  40. package/dist_ts/opsserver/handlers/dns-authority.handler.js +108 -0
  41. package/dist_ts/opsserver/handlers/dns-provider.handler.js +5 -1
  42. package/dist_ts/opsserver/handlers/domain.handler.js +9 -1
  43. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +2 -2
  44. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  45. package/dist_ts/opsserver/handlers/index.js +2 -1
  46. package/dist_ts_interfaces/data/dns-authority.d.ts +98 -0
  47. package/dist_ts_interfaces/data/dns-authority.js +26 -0
  48. package/dist_ts_interfaces/data/index.d.ts +1 -0
  49. package/dist_ts_interfaces/data/index.js +2 -1
  50. package/dist_ts_interfaces/data/route-management.d.ts +9 -2
  51. package/dist_ts_interfaces/data/route-management.js +3 -1
  52. package/dist_ts_interfaces/requests/certificate.d.ts +23 -0
  53. package/dist_ts_interfaces/requests/certificate.js +1 -1
  54. package/dist_ts_interfaces/requests/dns-authority.d.ts +80 -0
  55. package/dist_ts_interfaces/requests/dns-authority.js +3 -0
  56. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  57. package/dist_ts_interfaces/requests/index.js +2 -1
  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/domain-ownership.ts +272 -0
  73. package/ts/dns/index.ts +2 -0
  74. package/ts/dns/manager.dns-authority.ts +558 -0
  75. package/ts/dns/manager.dns.ts +373 -27
  76. package/ts/email/classes.accepted-email-spool.ts +98 -21
  77. package/ts/email/classes.smartmta-blob-storage-manager.ts +10 -2
  78. package/ts/errors/error.codes.ts +4 -0
  79. package/ts/opsserver/classes.opsserver.ts +2 -0
  80. package/ts/opsserver/handlers/acme-config.handler.ts +7 -0
  81. package/ts/opsserver/handlers/certificate.handler.ts +103 -8
  82. package/ts/opsserver/handlers/config.handler.ts +3 -1
  83. package/ts/opsserver/handlers/dns-authority.handler.ts +142 -0
  84. package/ts/opsserver/handlers/dns-provider.handler.ts +6 -0
  85. package/ts/opsserver/handlers/domain.handler.ts +12 -0
  86. package/ts/opsserver/handlers/gatewayclient.handler.ts +1 -1
  87. package/ts/opsserver/handlers/index.ts +1 -0
  88. package/ts/readme.md +1 -1
  89. package/ts_web/00_commitinfo_data.ts +1 -1
@@ -35,10 +35,13 @@ import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyMana
35
35
  import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
36
36
  import { applyDefaultInboundPolicy } from './email/inbound-policy.js';
37
37
  import { DnsManager } from './dns/manager.dns.js';
38
+ import { DnsAuthorityManager } from './dns/manager.dns-authority.js';
38
39
  import { DnsServerRuntime } from './dns/classes.dns-server-runtime.js';
39
40
  import { GatewayRouteDnsReconciler } from './dns/classes.gateway-route-dns-reconciler.js';
41
+ import { DomainOwnershipError } from './dns/domain-ownership.js';
40
42
  import { AcmeConfigManager } from './acme/manager.acme-config.js';
41
43
  import { SmartAcmeLifecycle } from './acme/classes.smartacme-lifecycle.js';
44
+ import { AcmePermanentFailureError, classifyAcmeFailure } from './acme/acme-failure-classification.js';
42
45
  import { AcceptedEmailSpool, EmailDomainManager, EmailRouteBuilder, EmailSettingsManager, MailDnsSync, MailEdgeEligibility, MailEgressCoordinator, ReleasedRemoteIngressEgressIdentitySource, SmartMtaBlobStorageManager, SmartMtaStorageManager, WorkAppMailManager, type ISmartMtaBlobStorageConfig, type TSmartMtaQueueItemLike } from './email/index.js';
43
46
  import { WebPushManager } from './webpush/index.js';
44
47
  import type { IRoute } from '../ts_interfaces/data/route-management.js';
@@ -95,17 +98,14 @@ export interface IDcRouterOptions {
95
98
  /**
96
99
  * The nameserver domains (e.g., ['ns1.example.com', 'ns2.example.com'])
97
100
  * These will automatically get A records pointing to publicIp or proxyIps[0]
98
- * These are what go in the NS records for ALL domains in dnsScopes
101
+ * A zone becomes authoritative by having its public NS records observed
102
+ * naming one of these — that is the only way into the authority set.
103
+ *
104
+ * There is deliberately no `dnsScopes` counterpart. Which zones dcrouter
105
+ * answers for is database state carrying its own delegation evidence, not
106
+ * deployment configuration; see ts/dns/manager.dns-authority.ts.
99
107
  */
100
108
  dnsNsDomains?: string[];
101
-
102
- /**
103
- * Domains this DNS server is authoritative for (e.g., ['example.com', 'mail.example.org'])
104
- * NS records will be auto-generated for these domains
105
- * Any DNS record outside these scopes will trigger a warning
106
- * Email domains with `internal-dns` mode must be included here
107
- */
108
- dnsScopes?: string[];
109
109
 
110
110
  /** Explicit UDP bind address for the embedded DNS server. Defaults to auto-detection. */
111
111
  dnsBindInterface?: string;
@@ -127,7 +127,7 @@ export interface IDcRouterOptions {
127
127
 
128
128
  /**
129
129
  * DNS records to register
130
- * Must be within the defined dnsScopes (or receive warning)
130
+ * Must be within a delegation-verified zone (or receive warning)
131
131
  * Only need A, CNAME, TXT, MX records (NS records auto-generated, SOA handled by smartdns)
132
132
  * Can use `useIngressProxy: false` to expose real server IP (defaults to true)
133
133
  */
@@ -314,6 +314,8 @@ export class DcRouter {
314
314
 
315
315
  // Domain / DNS management (DB-backed providers, domains, records)
316
316
  public dnsManager?: DnsManager;
317
+ /** Owns which zones dcrouter may answer for authoritatively, and proves it. */
318
+ public dnsAuthorityManager?: DnsAuthorityManager;
317
319
  public gatewayRouteDnsReconciler?: GatewayRouteDnsReconciler;
318
320
 
319
321
  // Durable, acknowledgeable platform configuration events (DB-backed)
@@ -523,7 +525,26 @@ export class DcRouter {
523
525
  .optional()
524
526
  .dependsOn('DcRouterDb')
525
527
  .withStart(async () => {
528
+ this.dnsAuthorityManager = new DnsAuthorityManager(
529
+ () => this.options.dnsNsDomains || [],
530
+ );
531
+ // Throws when the stored authority set is unreadable. That fails
532
+ // this service; DnsServerRuntime.setup() independently refuses to
533
+ // start on an 'unavailable' state, because dependsOn only orders
534
+ // services and does not gate them. An unknown authority set must
535
+ // never be served as an empty one.
536
+ await this.dnsAuthorityManager.start();
537
+
526
538
  this.dnsManager = new DnsManager(this.options);
539
+ // Ownership proof reads the delegation-verified set, so a zone
540
+ // verified at runtime counts immediately and a zone revoked at
541
+ // runtime stops counting immediately.
542
+ this.dnsManager.setAuthorityZonesResolver(
543
+ () => this.dnsAuthorityManager?.getEffectiveZoneNames() || [],
544
+ );
545
+ this.dnsAuthorityManager.setReconciler(
546
+ async (reasonArg) => await this.reconcileDnsAuthority(reasonArg),
547
+ );
527
548
  await this.dnsManager.start();
528
549
  })
529
550
  .withStop(async () => {
@@ -531,6 +552,10 @@ export class DcRouter {
531
552
  await this.dnsManager.stop();
532
553
  this.dnsManager = undefined;
533
554
  }
555
+ if (this.dnsAuthorityManager) {
556
+ await this.dnsAuthorityManager.stop();
557
+ this.dnsAuthorityManager = undefined;
558
+ }
534
559
  })
535
560
  .withRetry({ maxRetries: 1, baseDelayMs: 500 }),
536
561
  );
@@ -743,14 +768,33 @@ export class DcRouter {
743
768
  } catch (err: unknown) {
744
769
  logger.log('error', `Failed to sync Remote Ingress allowed edges: ${(err as Error).message}`);
745
770
  }
746
- this.dnsServerRuntime.syncPrivateRouteOverrides(
747
- routes as IDcRouterRouteConfig[],
748
- );
771
+ try {
772
+ await this.dnsServerRuntime.syncPrivateRouteOverrides(
773
+ routes as IDcRouterRouteConfig[],
774
+ );
775
+ } catch (err: unknown) {
776
+ logger.log(
777
+ 'error',
778
+ `Failed to sync the DNS private-route overlay: ${(err as Error).message}`,
779
+ );
780
+ }
749
781
  },
750
782
  (preparedRoutes) => buildHttpRedirectRuntimeRoutes(preparedRoutes || []),
751
783
  (storedRoute: IRoute) => this.emailRouteBuilder.hydrateStoredRouteForRuntime(storedRoute),
752
784
  (routes) => this.routePolicyAugmenter.applyInboundProxyProtocolPolicies(routes),
753
785
  );
786
+ // Certificate requirements may only be created for domains whose
787
+ // ownership can be proven. Wired before initialize() so the startup
788
+ // audit and every subsequent mutation see the same source.
789
+ this.routeConfigManager.setDomainOwnershipSource({
790
+ listOwnershipZones: async () => {
791
+ if (!this.dnsManager) {
792
+ throw new Error('DnsManager is unavailable, domain ownership cannot be verified');
793
+ }
794
+ return await this.dnsManager.listOwnershipZones();
795
+ },
796
+ getAuthorityZones: () => this.dnsAuthorityManager?.getEffectiveZoneNames() || [],
797
+ });
754
798
  this.apiTokenManager = new ApiTokenManager();
755
799
  await this.apiTokenManager.initialize();
756
800
  this.gatewayClientManager = new GatewayClientManager();
@@ -833,12 +877,19 @@ export class DcRouter {
833
877
  logger.log('warn', 'EmailServer: dbConfig.enabled=false, skipping SMTP startup because accepted email requires durable DB persistence');
834
878
  }
835
879
 
836
- // DNS Server: optional, depends on SmartProxy
837
- if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0 && this.options.dnsScopes && this.options.dnsScopes.length > 0) {
838
- const dnsServerDeps = ['SmartProxy'];
839
- if (this.options.dbConfig?.enabled !== false) {
840
- dnsServerDeps.push('ConfigManagers', 'EmailServer');
841
- }
880
+ // DNS Server: optional, depends on SmartProxy and on the authority set.
881
+ //
882
+ // Registration used to require a non-empty bootstrap `dnsScopes`, which
883
+ // meant a router whose authority lived entirely in the database never
884
+ // started its DNS server at all. The gate is now the nameserver identity —
885
+ // without `dnsNsDomains` there is nothing a delegation could name, so no
886
+ // zone could ever be verified — plus the database, because that is where
887
+ // authority comes from. `DnsManager` is listed as a dependency so it starts
888
+ // first — it owns `DnsAuthorityManager` — but ordering is all `dependsOn`
889
+ // provides, so `DnsServerRuntime.setup()` checks the authority state itself
890
+ // rather than trusting the graph.
891
+ if (this.options.dnsNsDomains?.length && this.options.dbConfig?.enabled !== false) {
892
+ const dnsServerDeps = ['SmartProxy', 'DnsManager', 'ConfigManagers', 'EmailServer'];
842
893
  this.serviceManager.addService(
843
894
  new plugins.taskbuffer.Service('DnsServer')
844
895
  .optional()
@@ -856,6 +907,12 @@ export class DcRouter {
856
907
  })
857
908
  .withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
858
909
  );
910
+ } else if (this.options.dnsNsDomains?.length) {
911
+ logger.log(
912
+ 'warn',
913
+ 'DnsServer: dbConfig.enabled=false, skipping the embedded DNS server because DNS authority is '
914
+ + 'delegation-verified state held in the database — without it there is no zone dcrouter may answer for',
915
+ );
859
916
  }
860
917
 
861
918
  // RADIUS Server: optional, no dependency on SmartProxy
@@ -899,7 +956,7 @@ export class DcRouter {
899
956
  const mailDnsSyncDependencies = [
900
957
  'DnsManager', 'EmailDomainManager', 'EmailServer', 'RemoteIngress',
901
958
  ];
902
- if (this.options.dnsNsDomains?.length && this.options.dnsScopes?.length) {
959
+ if (this.options.dnsNsDomains?.length) {
903
960
  mailDnsSyncDependencies.push('DnsServer');
904
961
  }
905
962
 
@@ -1052,8 +1109,9 @@ export class DcRouter {
1052
1109
  }
1053
1110
 
1054
1111
  // DNS service summary
1055
- if (this.dnsServer && this.options.dnsNsDomains && this.options.dnsScopes) {
1056
- logger.log('info', `DNS Service: nameservers=[${this.options.dnsNsDomains.join(', ')}], authoritative for ${this.options.dnsScopes.length} domains [${this.options.dnsScopes.join(', ')}], UDP:53, DoH enabled`);
1112
+ if (this.dnsServer && this.options.dnsNsDomains) {
1113
+ const authorityZones = this.dnsAuthorityManager?.getEffectiveZoneNames() || [];
1114
+ logger.log('info', `DNS Service: nameservers=[${this.options.dnsNsDomains.join(', ')}], authoritative for ${authorityZones.length} delegation-verified zone(s) [${authorityZones.join(', ') || 'none'}], UDP:53, DoH enabled`);
1057
1115
  }
1058
1116
 
1059
1117
  // RADIUS service summary
@@ -1104,8 +1162,17 @@ export class DcRouter {
1104
1162
  } else {
1105
1163
  logger.log('info', `All ${running} services are running`);
1106
1164
  }
1165
+
1166
+ // Compare claimed authority against real delegation, in the background.
1167
+ //
1168
+ // Nothing used to check these two representations against each other, which
1169
+ // is how one zone sat declared in bootstrap config while four others were
1170
+ // live and delegated to our nameservers. Detached and advisory: it makes
1171
+ // network calls, so it must never delay or fail startup, and it never mutates
1172
+ // the authority set — a resolver blip at boot must not revoke authority.
1173
+ void this.dnsAuthorityManager?.logDelegationDrift();
1107
1174
  }
1108
-
1175
+
1109
1176
  /**
1110
1177
  * Set up the unified database (smartdata + LocalSmartDb or external MongoDB)
1111
1178
  */
@@ -1403,6 +1470,23 @@ export class DcRouter {
1403
1470
  return 'http01';
1404
1471
  }
1405
1472
 
1473
+ // Pre-flight: a hostname whose ownership we cannot prove has no zone
1474
+ // able to hold the DNS-01 challenge record, so the order can only ever
1475
+ // fail. Refuse before touching the per-domain retry budget — this is
1476
+ // the cause that silently consumed 31–45 attempts per domain and never
1477
+ // surfaced as anything but generic ACME noise.
1478
+ const ownership = await this.dnsManager!.resolveDomainOwnership(domain);
1479
+ if (!ownership.verified) {
1480
+ const permanentError = new DomainOwnershipError(
1481
+ ownership,
1482
+ 'Certificate provisioning',
1483
+ 'cert-provision-function',
1484
+ { data: { domain } },
1485
+ );
1486
+ eventComms.error(permanentError.message);
1487
+ throw permanentError;
1488
+ }
1489
+
1406
1490
  // Check backoff before attempting provision
1407
1491
  if (await scheduler.isInBackoff(domain)) {
1408
1492
  const info = await scheduler.getBackoffInfo(domain);
@@ -1444,6 +1528,21 @@ export class DcRouter {
1444
1528
  await scheduler.clearBackoff(domain);
1445
1529
  return result;
1446
1530
  } catch (err: unknown) {
1531
+ const classification = classifyAcmeFailure(err);
1532
+ if (classification.permanent) {
1533
+ // A configuration cause cannot be retried away. Consuming the
1534
+ // per-domain budget here is what hid four broken domains behind
1535
+ // ordinary backoff warnings for weeks, so it is left untouched and
1536
+ // the failure is raised as an attributable terminal error instead.
1537
+ const permanentError = new AcmePermanentFailureError(
1538
+ classification,
1539
+ `DNS-01 for ${domain}`,
1540
+ 'cert-provision-function',
1541
+ { data: { domain } },
1542
+ );
1543
+ eventComms.error(permanentError.message);
1544
+ throw permanentError;
1545
+ }
1447
1546
  const message = `DNS-01 failed for ${domain}: ${(err as Error).message}`;
1448
1547
  await scheduler.recordFailure(domain, message);
1449
1548
  eventComms.warn(message);
@@ -2200,6 +2299,77 @@ export class DcRouter {
2200
2299
  return await this.remoteIngressHubLifecycle.updateHubSettings(updates, updatedBy);
2201
2300
  }
2202
2301
 
2302
+ /**
2303
+ * Re-derive every piece of runtime state that depends on the effective DNS
2304
+ * authority set, after a zone gained or lost delegation-verified authority.
2305
+ *
2306
+ * Deliberately reuses the existing machinery rather than paralleling it:
2307
+ * `reconcileAuthoritativeZones()` drives the same `runtimeRegistrations`
2308
+ * registry that domain deletion uses, and the overlay and route-warning
2309
+ * refreshes are the same calls the domain mutation paths make. Throws on the
2310
+ * first failure so `DnsAuthorityManager` can roll the change back rather than
2311
+ * leave the router half-converted.
2312
+ */
2313
+ public async reconcileDnsAuthority(reasonArg: string): Promise<void> {
2314
+ if (!this.dnsManager) {
2315
+ throw new Error('DnsManager is unavailable, DNS authority cannot be reconciled');
2316
+ }
2317
+ logger.log('info', `Reconciling DNS authority: ${reasonArg}`, { zone: 'dns' });
2318
+
2319
+ // 0. The running DNS server's own zone set. This decides the *response
2320
+ // kind* — a name outside every configured zone is REFUSED even when a
2321
+ // handler answers other qtypes for it — so it has to move before the
2322
+ // handlers do, or a newly verified zone answers A while REFUSING AAAA
2323
+ // and SOA, which is precisely the production defect being fixed.
2324
+ this.dnsServerRuntime.syncAuthorityZones(reasonArg);
2325
+
2326
+ // 1. Zone handlers: register what gained proof, tear down what lost it.
2327
+ const zoneResult = await this.dnsManager.reconcileAuthoritativeZones();
2328
+
2329
+ // 2. Keep the persisted authoritative flag honest with the new verdicts.
2330
+ const flagsChanged = await this.dnsManager.syncAuthoritativeFlags();
2331
+
2332
+ // 3. Certificate requirements: re-evaluate which routes are now provable.
2333
+ await this.routeConfigManager?.refreshDomainOwnershipWarnings();
2334
+
2335
+ // 4. The ownership-gated private-route overlay.
2336
+ await this.resyncPrivateRouteDnsOverlay(reasonArg);
2337
+
2338
+ logger.log(
2339
+ 'info',
2340
+ `DNS authority reconciled (${reasonArg}): +${zoneResult.registered.length} / -${zoneResult.unregistered.length} apex NS zone(s), `
2341
+ + `${flagsChanged} domain authoritative flag(s) corrected`,
2342
+ { zone: 'dns' },
2343
+ );
2344
+ }
2345
+
2346
+ /**
2347
+ * Re-derive the private-route DNS overlay from the currently applied route set.
2348
+ *
2349
+ * The overlay is derived from routes but gated on domain ownership, so a domain
2350
+ * mutation can invalidate it without any route changing. Deleting a DomainDoc
2351
+ * is the case that matters: without this the overlay keeps answering `aa` for a
2352
+ * hostname whose zone we no longer manage until some unrelated route apply
2353
+ * happens. Named here so the mutation site can trigger its own invalidation
2354
+ * instead of forcing a full route re-apply.
2355
+ */
2356
+ public async resyncPrivateRouteDnsOverlay(reasonArg: string): Promise<void> {
2357
+ const appliedRoutes = this.smartProxy?.routeManager.getRoutes();
2358
+ if (!appliedRoutes) {
2359
+ return;
2360
+ }
2361
+ try {
2362
+ await this.dnsServerRuntime.syncPrivateRouteOverrides(
2363
+ appliedRoutes as IDcRouterRouteConfig[],
2364
+ );
2365
+ } catch (err: unknown) {
2366
+ logger.log(
2367
+ 'error',
2368
+ `Failed to re-sync the DNS private-route overlay after ${reasonArg}: ${(err as Error).message}`,
2369
+ );
2370
+ }
2371
+ }
2372
+
2203
2373
  /** Restart SmartProxy after RemoteIngress hub settings changed listener wiring. Called by RemoteIngressHubLifecycle. */
2204
2374
  public async restartSmartProxyForRemoteIngressSettings(): Promise<void> {
2205
2375
  await this.queueSmartProxyLifecycleTask(async () => {
@@ -16,6 +16,12 @@ import type {
16
16
  } from '../../ts_interfaces/data/route-management.js';
17
17
  import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
18
18
  import { type IHttp3Config, augmentRouteWithHttp3, routeNeedsHttpProtocol } from '../http3/index.js';
19
+ import {
20
+ DomainOwnershipError,
21
+ resolveDomainOwnership,
22
+ type IDomainOwnershipUnverified,
23
+ type IDomainOwnershipZone,
24
+ } from '../dns/domain-ownership.js';
19
25
  import type { ReferenceResolver } from './classes.reference-resolver.js';
20
26
  import { SourcePolicyCompiler } from './classes.source-policy-compiler.js';
21
27
  import { deriveHttpRedirects } from './helpers.http-redirects.js';
@@ -32,6 +38,23 @@ export interface IRouteMutationResult {
32
38
  message?: string;
33
39
  }
34
40
 
41
+ /**
42
+ * Supplies the ownership inputs a route's certificate requirement is checked
43
+ * against. Injected (rather than importing DnsManager) so route management keeps
44
+ * its single direction of dependency and stays unit-testable.
45
+ */
46
+ export interface IRouteDomainOwnershipSource {
47
+ listOwnershipZones: () => Promise<IDomainOwnershipZone[]>;
48
+ getAuthorityZones: () => string[];
49
+ }
50
+
51
+ /** A route hostname whose certificate requirement has no ownership proof. */
52
+ export interface IUnverifiedRouteDomain {
53
+ routeId: string;
54
+ routeName: string;
55
+ ownership: IDomainOwnershipUnverified;
56
+ }
57
+
35
58
  interface IRouteMutationOptions {
36
59
  trustedManagedMutation?: boolean;
37
60
  replaceMetadata?: boolean;
@@ -72,6 +95,7 @@ export class RouteConfigManager {
72
95
  private routes = new Map<string, IRoute>();
73
96
  private warnings: IRouteWarning[] = [];
74
97
  private routeUpdateMutex = new RouteUpdateMutex();
98
+ private domainOwnershipSource?: IRouteDomainOwnershipSource;
75
99
 
76
100
  constructor(
77
101
  private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
@@ -99,6 +123,155 @@ export class RouteConfigManager {
99
123
  this.getVpnClientAccessForRoute = resolver;
100
124
  }
101
125
 
126
+ /**
127
+ * Wire the ownership inputs used to gate certificate requirements. Until this
128
+ * is set, any route asking for an automatic certificate is refused — a missing
129
+ * wiring must never silently disable the gate.
130
+ */
131
+ public setDomainOwnershipSource(source?: IRouteDomainOwnershipSource): void {
132
+ this.domainOwnershipSource = source;
133
+ }
134
+
135
+ // =========================================================================
136
+ // Certificate requirement / domain ownership gate
137
+ // =========================================================================
138
+
139
+ /**
140
+ * Hostnames for which this route asks dcrouter to obtain a certificate.
141
+ * `tls.certificate === 'auto'` on a terminating route is exactly what makes
142
+ * SmartProxy call certProvisionFunction, i.e. what starts an ACME order.
143
+ */
144
+ private collectAutoCertificateHostnames(route: IDcRouterRouteConfig): string[] {
145
+ const tls = route.action?.tls;
146
+ if (!tls || tls.mode === 'passthrough' || tls.certificate !== 'auto') {
147
+ return [];
148
+ }
149
+ const domains = route.match?.domains;
150
+ const entries = Array.isArray(domains)
151
+ ? domains
152
+ : typeof domains === 'string'
153
+ ? domains.split(',')
154
+ : [];
155
+ const hostnames = new Set<string>();
156
+ for (const entry of entries) {
157
+ const trimmed = typeof entry === 'string' ? entry.trim() : '';
158
+ if (trimmed) hostnames.add(trimmed);
159
+ }
160
+ return [...hostnames];
161
+ }
162
+
163
+ /**
164
+ * Refuse a route whose certificate requirement covers a domain we cannot prove
165
+ * we own. This is the point of attribution: the operator making the change gets
166
+ * the reason, instead of a DNS-01 order failing hours later and consuming a
167
+ * per-domain retry budget that no retry could ever satisfy.
168
+ */
169
+ private async assertCertificateRequirementOwnership(
170
+ route: IDcRouterRouteConfig,
171
+ operation: string,
172
+ ): Promise<void> {
173
+ const hostnames = this.collectAutoCertificateHostnames(route);
174
+ if (hostnames.length === 0) {
175
+ return;
176
+ }
177
+ if (!this.domainOwnershipSource) {
178
+ throw new Error(
179
+ `${operation} refused: domain ownership verification is unavailable, so a certificate requirement for `
180
+ + `${hostnames.join(', ')} cannot be checked. This is a wiring defect, not a configuration problem.`,
181
+ );
182
+ }
183
+ const zones = await this.domainOwnershipSource.listOwnershipZones();
184
+ const authorityZones = this.domainOwnershipSource.getAuthorityZones();
185
+ for (const hostname of hostnames) {
186
+ const ownership = resolveDomainOwnership({ fqdn: hostname, zones, authorityZones });
187
+ if (!ownership.verified) {
188
+ throw new DomainOwnershipError(ownership, operation, 'route-config-manager', {
189
+ data: { routeName: route.name },
190
+ });
191
+ }
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Audit already-stored routes for certificate requirements on unverified
197
+ * domains.
198
+ *
199
+ * Deliberately an audit and not a refusal: these routes are already live, and
200
+ * a route can be serving traffic on a certificate issued while the domain was
201
+ * still verifiable, or on a static certificate elsewhere in the set. Refusing
202
+ * them at startup would convert a latent misconfiguration into an immediate
203
+ * outage, and the incident these checks exist for cost us silence, not serving.
204
+ * New and updated routes are refused outright; existing ones are surfaced.
205
+ */
206
+ public async auditRouteDomainOwnership(): Promise<IUnverifiedRouteDomain[]> {
207
+ if (!this.domainOwnershipSource) {
208
+ return [];
209
+ }
210
+ const auditableRoutes = [...this.routes.values()].filter((storedRoute) => {
211
+ return storedRoute.enabled
212
+ && this.collectAutoCertificateHostnames(storedRoute.route).length > 0;
213
+ });
214
+ if (auditableRoutes.length === 0) {
215
+ return [];
216
+ }
217
+
218
+ const zones = await this.domainOwnershipSource.listOwnershipZones();
219
+ const authorityZones = this.domainOwnershipSource.getAuthorityZones();
220
+ const findings: IUnverifiedRouteDomain[] = [];
221
+ for (const storedRoute of auditableRoutes) {
222
+ for (const hostname of this.collectAutoCertificateHostnames(storedRoute.route)) {
223
+ const ownership = resolveDomainOwnership({ fqdn: hostname, zones, authorityZones });
224
+ if (ownership.verified) continue;
225
+ findings.push({
226
+ routeId: storedRoute.id,
227
+ routeName: storedRoute.route.name || storedRoute.id,
228
+ ownership,
229
+ });
230
+ }
231
+ }
232
+ return findings;
233
+ }
234
+
235
+ /**
236
+ * Run the audit, publish it as route warnings, and log each finding at `error`
237
+ * so it reaches the ops log stream rather than only the warnings panel.
238
+ *
239
+ * Public because a DNS-authority change can make a previously unprovable
240
+ * certificate requirement provable (or the reverse) without any route changing.
241
+ */
242
+ public async refreshDomainOwnershipWarnings(): Promise<void> {
243
+ let findings: IUnverifiedRouteDomain[];
244
+ try {
245
+ findings = await this.auditRouteDomainOwnership();
246
+ } catch (err: unknown) {
247
+ logger.log(
248
+ 'error',
249
+ `Route domain ownership audit failed: ${(err as Error).message}. `
250
+ + 'Certificate requirements on unverified domains may be present and unreported.',
251
+ );
252
+ return;
253
+ }
254
+
255
+ this.warnings = this.warnings.filter(
256
+ (warning) => warning.type !== 'unverified-domain-ownership',
257
+ );
258
+ for (const finding of findings) {
259
+ const message = `Route '${finding.routeName}' (id: ${finding.routeId}) requests an automatic certificate for `
260
+ + `'${finding.ownership.fqdn}', whose ownership is unverified (${finding.ownership.reason}): `
261
+ + `${finding.ownership.detail}. Certificate provisioning for this domain cannot succeed until that is fixed.`;
262
+ this.warnings.push({
263
+ type: 'unverified-domain-ownership',
264
+ routeName: finding.routeName,
265
+ message,
266
+ });
267
+ logger.log('error', message, {
268
+ routeId: finding.routeId,
269
+ domain: finding.ownership.fqdn,
270
+ ownershipReason: finding.ownership.reason,
271
+ });
272
+ }
273
+ }
274
+
102
275
  public async runExclusiveRouteUpdate<T>(fn: () => Promise<T>): Promise<T> {
103
276
  return await this.routeUpdateMutex.runExclusive(fn);
104
277
  }
@@ -118,6 +291,7 @@ export class RouteConfigManager {
118
291
  await this.seedRoutes(dnsRoutes, 'dns');
119
292
  this.computeWarnings();
120
293
  this.logWarnings();
294
+ await this.refreshDomainOwnershipWarnings();
121
295
  await this.applyRoutes();
122
296
  }
123
297
 
@@ -191,6 +365,12 @@ export class RouteConfigManager {
191
365
  if (sourceBindingsValidationError) {
192
366
  throw new Error(sourceBindingsValidationError);
193
367
  }
368
+ // Nothing is persisted or applied until the certificate requirement is proven
369
+ // to cover only domains we own. A disabled route provisions nothing, so only
370
+ // routes that will actually be applied are gated.
371
+ if (enabled) {
372
+ await this.assertCertificateRequirementOwnership(route, 'Route creation');
373
+ }
194
374
 
195
375
  const stored: IRoute = {
196
376
  id,
@@ -334,10 +514,27 @@ export class RouteConfigManager {
334
514
  return { success: false, message: sourceBindingsValidationError };
335
515
  }
336
516
 
517
+ // An update can introduce a certificate requirement, add a hostname to an
518
+ // existing one, or re-enable a route that carries one — all of them must
519
+ // clear the ownership gate before anything is persisted or applied.
520
+ // Disabling is never gated: an operator must always be able to turn a route
521
+ // off, including the ones this gate is complaining about.
522
+ if (stored.enabled) {
523
+ try {
524
+ await this.assertCertificateRequirementOwnership(stored.route, 'Route update');
525
+ } catch (err: unknown) {
526
+ stored.route = previousRoute;
527
+ stored.metadata = previousMetadata;
528
+ stored.enabled = previousEnabled;
529
+ return { success: false, message: (err as Error).message };
530
+ }
531
+ }
532
+
337
533
  stored.updatedAt = Date.now();
338
534
 
339
535
  await this.persistRoute(stored);
340
536
  await this.applyRoutes();
537
+ await this.refreshDomainOwnershipWarnings();
341
538
  return { success: true };
342
539
  }
343
540
 
@@ -0,0 +1,49 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import { DcRouterDb } from '../classes.dcrouter-db.js';
3
+ import type { IDnsAuthorityZone } from '../../../ts_interfaces/data/dns-authority.js';
4
+
5
+ const getDb = () => DcRouterDb.getInstance().getDb();
6
+
7
+ /**
8
+ * Singleton DNS-authority document. One row per dcrouter instance, keyed on the
9
+ * fixed `settingsId`, following the `AcmeConfigDoc` / `RemoteIngressHubSettingsDoc`
10
+ * pattern rather than introducing a third settings shape.
11
+ *
12
+ * Holds the zones whose authority was **proven** by an observed public
13
+ * delegation to our nameservers. This is the authority set — the whole of it.
14
+ * Deployment configuration contributes nothing: `dnsScopes` used to add an
15
+ * always-in-effect floor, and duplicating one fact into two places is what let
16
+ * the two representations drift, which is the bug class this document closes.
17
+ *
18
+ * Reading it is therefore load-bearing. `DnsAuthorityManager.start()` treats an
19
+ * absent document (known-empty: claim nothing) differently from an unreadable
20
+ * one (unknown: refuse to start), because rendering the second as the first
21
+ * would turn a database blip into every zone dropping off the air.
22
+ */
23
+ @plugins.smartdata.Collection(() => getDb())
24
+ export class DnsAuthorityDoc extends plugins.smartdata.SmartDataDbDoc<DnsAuthorityDoc, DnsAuthorityDoc> {
25
+ @plugins.smartdata.unI()
26
+ @plugins.smartdata.svDb()
27
+ public settingsId: string = 'dns-authority-settings';
28
+
29
+ /**
30
+ * Delegation-verified zones. Each entry carries the evidence that justified
31
+ * it, so a later audit can tell what was observed and when.
32
+ */
33
+ @plugins.smartdata.svDb()
34
+ public verifiedZones: IDnsAuthorityZone[] = [];
35
+
36
+ @plugins.smartdata.svDb()
37
+ public updatedAt: number = 0;
38
+
39
+ @plugins.smartdata.svDb()
40
+ public updatedBy: string = '';
41
+
42
+ constructor() {
43
+ super();
44
+ }
45
+
46
+ public static async load(): Promise<DnsAuthorityDoc | null> {
47
+ return await DnsAuthorityDoc.getInstance({ settingsId: 'dns-authority-settings' });
48
+ }
49
+ }
@@ -34,6 +34,7 @@ export * from './classes.accounting-session.doc.js';
34
34
  export * from './classes.dns-provider.doc.js';
35
35
  export * from './classes.domain.doc.js';
36
36
  export * from './classes.dns-record.doc.js';
37
+ export * from './classes.dns-authority.doc.js';
37
38
 
38
39
  // ACME configuration (singleton)
39
40
  export * from './classes.acme-config.doc.js';