@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.
- package/deno.json +1 -1
- package/dist_serve/bundle.js +360 -360
- package/dist_ts/00_commitinfo_data.js +2 -2
- package/dist_ts/acme/acme-failure-classification.d.ts +64 -0
- package/dist_ts/acme/acme-failure-classification.js +114 -0
- package/dist_ts/acme/classes.smartacme-lifecycle.d.ts +42 -0
- package/dist_ts/acme/classes.smartacme-lifecycle.js +75 -4
- package/dist_ts/acme/index.d.ts +1 -0
- package/dist_ts/acme/index.js +2 -1
- package/dist_ts/classes.dcrouter.d.ts +33 -9
- package/dist_ts/classes.dcrouter.js +145 -11
- package/dist_ts/config/classes.route-config-manager.d.ts +56 -0
- package/dist_ts/config/classes.route-config-manager.js +161 -1
- package/dist_ts/db/documents/classes.dns-authority.doc.d.ts +30 -0
- package/dist_ts/db/documents/classes.dns-authority.doc.js +108 -0
- package/dist_ts/db/documents/index.d.ts +1 -0
- package/dist_ts/db/documents/index.js +2 -1
- package/dist_ts/dns/classes.dns-server-runtime.d.ts +109 -1
- package/dist_ts/dns/classes.dns-server-runtime.js +212 -34
- package/dist_ts/dns/classes.gateway-route-dns-reconciler.d.ts +7 -0
- package/dist_ts/dns/classes.gateway-route-dns-reconciler.js +37 -6
- package/dist_ts/dns/domain-ownership.d.ts +111 -0
- package/dist_ts/dns/domain-ownership.js +152 -0
- package/dist_ts/dns/index.d.ts +2 -0
- package/dist_ts/dns/index.js +3 -1
- package/dist_ts/dns/manager.dns-authority.d.ts +143 -0
- package/dist_ts/dns/manager.dns-authority.js +481 -0
- package/dist_ts/dns/manager.dns.d.ts +121 -12
- package/dist_ts/dns/manager.dns.js +298 -28
- package/dist_ts/errors/error.codes.d.ts +4 -0
- package/dist_ts/errors/error.codes.js +5 -1
- package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
- package/dist_ts/opsserver/classes.opsserver.js +3 -1
- package/dist_ts/opsserver/handlers/acme-config.handler.js +6 -1
- package/dist_ts/opsserver/handlers/certificate.handler.d.ts +16 -0
- package/dist_ts/opsserver/handlers/certificate.handler.js +96 -10
- package/dist_ts/opsserver/handlers/config.handler.js +4 -2
- package/dist_ts/opsserver/handlers/dns-authority.handler.d.ts +20 -0
- package/dist_ts/opsserver/handlers/dns-authority.handler.js +108 -0
- package/dist_ts/opsserver/handlers/dns-provider.handler.js +5 -1
- package/dist_ts/opsserver/handlers/domain.handler.js +9 -1
- package/dist_ts/opsserver/handlers/gatewayclient.handler.js +2 -2
- package/dist_ts/opsserver/handlers/index.d.ts +1 -0
- package/dist_ts/opsserver/handlers/index.js +2 -1
- package/dist_ts_interfaces/data/dns-authority.d.ts +98 -0
- package/dist_ts_interfaces/data/dns-authority.js +26 -0
- package/dist_ts_interfaces/data/index.d.ts +1 -0
- package/dist_ts_interfaces/data/index.js +2 -1
- package/dist_ts_interfaces/data/route-management.d.ts +9 -2
- package/dist_ts_interfaces/data/route-management.js +3 -1
- package/dist_ts_interfaces/requests/certificate.d.ts +23 -0
- package/dist_ts_interfaces/requests/certificate.js +1 -1
- package/dist_ts_interfaces/requests/dns-authority.d.ts +80 -0
- package/dist_ts_interfaces/requests/dns-authority.js +3 -0
- package/dist_ts_interfaces/requests/index.d.ts +1 -0
- package/dist_ts_interfaces/requests/index.js +2 -1
- package/dist_ts_migrations/index.js +122 -9
- package/dist_ts_oci_container/index.js +9 -4
- package/dist_ts_web/00_commitinfo_data.js +2 -2
- package/package.json +1 -1
- package/readme.hints.md +412 -0
- package/readme.md +48 -6
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/acme/acme-failure-classification.ts +201 -0
- package/ts/acme/classes.smartacme-lifecycle.ts +104 -3
- package/ts/acme/index.ts +1 -0
- package/ts/classes.dcrouter.ts +193 -23
- package/ts/config/classes.route-config-manager.ts +197 -0
- package/ts/db/documents/classes.dns-authority.doc.ts +49 -0
- package/ts/db/documents/index.ts +1 -0
- package/ts/dns/classes.dns-server-runtime.ts +257 -38
- package/ts/dns/classes.gateway-route-dns-reconciler.ts +41 -6
- package/ts/dns/domain-ownership.ts +272 -0
- package/ts/dns/index.ts +2 -0
- package/ts/dns/manager.dns-authority.ts +558 -0
- package/ts/dns/manager.dns.ts +373 -27
- package/ts/errors/error.codes.ts +4 -0
- package/ts/opsserver/classes.opsserver.ts +2 -0
- package/ts/opsserver/handlers/acme-config.handler.ts +7 -0
- package/ts/opsserver/handlers/certificate.handler.ts +103 -8
- package/ts/opsserver/handlers/config.handler.ts +3 -1
- package/ts/opsserver/handlers/dns-authority.handler.ts +142 -0
- package/ts/opsserver/handlers/dns-provider.handler.ts +6 -0
- package/ts/opsserver/handlers/domain.handler.ts +12 -0
- package/ts/opsserver/handlers/gatewayclient.handler.ts +1 -1
- package/ts/opsserver/handlers/index.ts +1 -0
- package/ts/readme.md +1 -1
- package/ts_web/00_commitinfo_data.ts +1 -1
package/ts/classes.dcrouter.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
-
|
|
747
|
-
|
|
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
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
|
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
|
|
1056
|
-
|
|
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
|
+
}
|
package/ts/db/documents/index.ts
CHANGED
|
@@ -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';
|