@serve.zone/dcrouter 17.10.2 → 18.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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_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/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
|
@@ -3,9 +3,25 @@ import { logger } from '../logger.js';
|
|
|
3
3
|
import { buildEmailDnsRecords } from '../email/index.js';
|
|
4
4
|
import type { DcRouter } from '../classes.dcrouter.js';
|
|
5
5
|
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
|
6
|
+
import {
|
|
7
|
+
resolveDomainOwnership,
|
|
8
|
+
type IDomainOwnershipZone,
|
|
9
|
+
} from './domain-ownership.js';
|
|
6
10
|
|
|
7
11
|
type TDnsRecordSeed = { name: string; type: string; value: string; ttl?: number; useIngressProxy?: boolean };
|
|
8
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The zone smartdns is handed when the authority set is empty.
|
|
15
|
+
*
|
|
16
|
+
* smartdns falls back to `[dnssecZone]` whenever `authoritativeZones` is empty,
|
|
17
|
+
* so this value decides what an *unauthoritative* dcrouter would claim. It has
|
|
18
|
+
* to be a name that can never match a real question, or a database that has not
|
|
19
|
+
* been seeded yet would quietly start asserting authority over whatever
|
|
20
|
+
* placeholder we picked. `.invalid` is reserved by RFC 2606 and is guaranteed
|
|
21
|
+
* never to be delegated to anybody.
|
|
22
|
+
*/
|
|
23
|
+
export const NO_AUTHORITY_SENTINEL_ZONE = 'no-authority.invalid';
|
|
24
|
+
|
|
9
25
|
/**
|
|
10
26
|
* Sets up and feeds the embedded authoritative smartdns server: validates the
|
|
11
27
|
* DNS configuration, generates authoritative/email/DKIM records, applies
|
|
@@ -21,8 +37,37 @@ export class DnsServerRuntime {
|
|
|
21
37
|
|
|
22
38
|
private privateRouteHostnames = new Set<string>();
|
|
23
39
|
private privateRouteTargetIp?: string;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The array instance handed to smartdns as `authoritativeZones`.
|
|
43
|
+
*
|
|
44
|
+
* smartdns keeps the options object by reference and re-reads this array on
|
|
45
|
+
* every query, so mutating it in place changes the authority set of a
|
|
46
|
+
* *running* server.
|
|
47
|
+
*
|
|
48
|
+
* **This is a consumer-side shim, and it is sequenced for replacement.**
|
|
49
|
+
* `authoritativeZones` is a public option, but "the array is retained by
|
|
50
|
+
* reference and re-read per query" is storage behaviour, not a documented
|
|
51
|
+
* contract. The proper fix is a `setAuthoritativeZones()` on smartdns' own
|
|
52
|
+
* `DnsServer`; it cannot be written here because that release does not exist
|
|
53
|
+
* yet, and the only alternative available today — re-creating the server to
|
|
54
|
+
* change its zones — drops UDP/TCP 53, which is the 30–60 s public outage
|
|
55
|
+
* this entire path exists to remove.
|
|
56
|
+
*
|
|
57
|
+
* Guarded two ways until the setter ships: `assertLiveAuthorityZones()` logs
|
|
58
|
+
* at `error` if the reference is no longer shared after construction, and a
|
|
59
|
+
* test asserts that identity against the installed smartdns so a release that
|
|
60
|
+
* starts copying the array fails the suite rather than production.
|
|
61
|
+
*/
|
|
62
|
+
private readonly authoritativeZones: string[] = [];
|
|
63
|
+
|
|
24
64
|
constructor(private dcRouterRef: DcRouter) {}
|
|
25
65
|
|
|
66
|
+
/** The authority set, from the database and nowhere else. */
|
|
67
|
+
private effectiveAuthorityZones(): string[] {
|
|
68
|
+
return this.dcRouterRef.dnsAuthorityManager?.getEffectiveZoneNames() || [];
|
|
69
|
+
}
|
|
70
|
+
|
|
26
71
|
/**
|
|
27
72
|
* Create the DNS server, start it on UDP, wire metrics/logging, and
|
|
28
73
|
* register all generated records.
|
|
@@ -33,8 +78,41 @@ export class DnsServerRuntime {
|
|
|
33
78
|
throw new Error('dnsNsDomains is required for DNS server setup');
|
|
34
79
|
}
|
|
35
80
|
|
|
36
|
-
|
|
37
|
-
|
|
81
|
+
// Refuse to start when the authority set is *unknown*.
|
|
82
|
+
//
|
|
83
|
+
// This guard is load-bearing and cannot be delegated to the service graph.
|
|
84
|
+
// `DnsServer.dependsOn('DnsManager')` only orders startup levels — taskbuffer
|
|
85
|
+
// starts later levels even when an earlier optional service failed — so
|
|
86
|
+
// without this check an unreadable authority document would leave
|
|
87
|
+
// `dnsAuthorityManager` undefined, `effectiveAuthorityZones()` would return
|
|
88
|
+
// `[]`, and the server would come up REFUSING every query. That is exactly
|
|
89
|
+
// the collapse of "unknown" into "claim nothing" that the three-state model
|
|
90
|
+
// exists to prevent, and it would be indistinguishable from a legitimately
|
|
91
|
+
// empty database.
|
|
92
|
+
const authorityManager = this.dcRouterRef.dnsAuthorityManager;
|
|
93
|
+
if (!authorityManager || authorityManager.getState() === 'unavailable') {
|
|
94
|
+
throw new Error(
|
|
95
|
+
'DNS authority is unknown (the authority manager is '
|
|
96
|
+
+ `${authorityManager ? 'unavailable' : 'not initialized'}), so the DNS server will not start. `
|
|
97
|
+
+ 'dcrouter serves DNS authority from the database alone; answering with an empty authority set would '
|
|
98
|
+
+ 'assert "we are authoritative for nothing", which is a claim an unreadable database cannot back. '
|
|
99
|
+
+ 'Restore database access and restart.',
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A *known*-empty set is different, and must not stop the server: DoH
|
|
104
|
+
// routes, the private-route overlay and, above all, the ability to pick up
|
|
105
|
+
// a zone the moment it is verified all depend on the server being up.
|
|
106
|
+
// Fail closed on *authority*, never silently on *service*.
|
|
107
|
+
const bootAuthorityZones = this.syncAuthorityZones('DNS server setup');
|
|
108
|
+
if (bootAuthorityZones.length === 0) {
|
|
109
|
+
logger.log(
|
|
110
|
+
'error',
|
|
111
|
+
'DNS server is starting with an empty authority set: no zone has been delegation-verified, so every '
|
|
112
|
+
+ 'query will be REFUSED until one is. This is what an unseeded database looks like — read the startup '
|
|
113
|
+
+ 'delegation drift audit for the list of zones delegated to us that we are refusing to serve.',
|
|
114
|
+
{ zone: 'dns' },
|
|
115
|
+
);
|
|
38
116
|
}
|
|
39
117
|
|
|
40
118
|
const primaryNameserver = options.dnsNsDomains[0];
|
|
@@ -71,13 +149,19 @@ export class DnsServerRuntime {
|
|
|
71
149
|
udpBindInterface: vmIpAddress,
|
|
72
150
|
httpsPort: 443, // Required but won't bind due to manual mode
|
|
73
151
|
manualHttpsMode: true, // Enable manual HTTPS socket handling
|
|
74
|
-
|
|
75
|
-
|
|
152
|
+
// The DNSSEC signing zone is baked into the Rust config at start and
|
|
153
|
+
// cannot be changed without a restart, so it is taken from the boot-time
|
|
154
|
+
// authority set — sorted, therefore stable across restarts rather than
|
|
155
|
+
// dependent on database insertion order.
|
|
156
|
+
dnssecZone: bootAuthorityZones[0] || NO_AUTHORITY_SENTINEL_ZONE,
|
|
157
|
+
// Live array, mutated in place by syncAuthorityZones(). See the field.
|
|
158
|
+
authoritativeZones: this.authoritativeZones,
|
|
76
159
|
primaryNameserver: primaryNameserver, // Automatically generates correct SOA records
|
|
77
160
|
// For now, use self-signed cert until we integrate with Let's Encrypt
|
|
78
161
|
httpsKey: '',
|
|
79
162
|
httpsCert: ''
|
|
80
163
|
});
|
|
164
|
+
this.assertLiveAuthorityZones(dnsServer);
|
|
81
165
|
this.dcRouterRef.dnsServer = dnsServer;
|
|
82
166
|
this.registerPrivateRouteHandler(dnsServer);
|
|
83
167
|
|
|
@@ -180,13 +264,80 @@ export class DnsServerRuntime {
|
|
|
180
264
|
this.dcRouterRef.mailDnsSync?.requestSync('DNS server attached');
|
|
181
265
|
}
|
|
182
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Re-derive the running server's authoritative zone set from the database.
|
|
269
|
+
*
|
|
270
|
+
* This is the half of authority that registering handlers cannot express.
|
|
271
|
+
* smartdns decides the *response kind* from `authoritativeZones` alone: a name
|
|
272
|
+
* inside a configured zone gets an answer or an authoritative negative, and a
|
|
273
|
+
* name outside every configured zone gets REFUSED — even when a handler is
|
|
274
|
+
* registered and answers other qtypes for that exact name. That asymmetry is
|
|
275
|
+
* the live production defect. `social.io` and `hard.global` are delegated to
|
|
276
|
+
* us and have handlers, so `A` is answered with the `aa` bit, while `AAAA` and
|
|
277
|
+
* `SOA` for the same name are REFUSED, and public recursives turn a REFUSED
|
|
278
|
+
* arm of a dual-stack lookup into SERVFAIL. Verifying a zone therefore has to
|
|
279
|
+
* update this set, not just register handlers, or the zone stays half-served.
|
|
280
|
+
*
|
|
281
|
+
* Idempotent, and safe to call on every authority reconcile.
|
|
282
|
+
*
|
|
283
|
+
* @returns the zone set now in effect.
|
|
284
|
+
*/
|
|
285
|
+
public syncAuthorityZones(reasonArg: string): string[] {
|
|
286
|
+
const nextZones = this.effectiveAuthorityZones();
|
|
287
|
+
const changed = nextZones.length !== this.authoritativeZones.length
|
|
288
|
+
|| nextZones.some((zone, index) => zone !== this.authoritativeZones[index]);
|
|
289
|
+
// In place: smartdns holds this exact array and re-reads it per query.
|
|
290
|
+
this.authoritativeZones.splice(0, this.authoritativeZones.length, ...nextZones);
|
|
291
|
+
if (changed) {
|
|
292
|
+
logger.log(
|
|
293
|
+
'info',
|
|
294
|
+
`DNS authoritative zone set is now [${nextZones.join(', ') || 'empty'}] (${reasonArg})`,
|
|
295
|
+
{ zone: 'dns' },
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return nextZones;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Prove smartdns kept our array rather than copying it.
|
|
303
|
+
*
|
|
304
|
+
* If this ever fails, authority silently freezes at its boot-time value and
|
|
305
|
+
* every runtime verification stops taking effect until a restart — a failure
|
|
306
|
+
* that is invisible from the outside because the server keeps answering
|
|
307
|
+
* correctly for the zones it started with. Logged rather than thrown: a frozen
|
|
308
|
+
* authority set is a degradation, and taking DNS down entirely over it would
|
|
309
|
+
* be worse than the fault it reports.
|
|
310
|
+
*/
|
|
311
|
+
private assertLiveAuthorityZones(
|
|
312
|
+
dnsServerArg: plugins.smartdns.dnsServerMod.DnsServer,
|
|
313
|
+
): void {
|
|
314
|
+
const heldZones = (dnsServerArg as unknown as {
|
|
315
|
+
options?: { authoritativeZones?: string[] };
|
|
316
|
+
}).options?.authoritativeZones;
|
|
317
|
+
if (heldZones === this.authoritativeZones) return;
|
|
318
|
+
logger.log(
|
|
319
|
+
'error',
|
|
320
|
+
'smartdns did not retain the authoritative zone array by reference, so DNS authority can no longer be '
|
|
321
|
+
+ 'reconciled without restarting the DNS server. Zones verified at runtime will register handlers but '
|
|
322
|
+
+ 'keep answering REFUSED for every qtype they do not handle until dcrouter is restarted.',
|
|
323
|
+
{ zone: 'dns' },
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
183
327
|
/**
|
|
184
328
|
* Keep an internal-only A-record overlay for exact route hostnames whose
|
|
185
329
|
* compiled source policy is private or whose only ingress is SmartVPN.
|
|
186
330
|
* Public routes compile without clientIp restrictions and are never added.
|
|
331
|
+
*
|
|
332
|
+
* Every hostname must clear the domain-ownership gate first. This overlay is
|
|
333
|
+
* only "internal" by intent, never by mechanism: smartdns marks any answer a
|
|
334
|
+
* registered handler produces authoritative, and the server listens on a public
|
|
335
|
+
* UDP/TCP 53. Ungated, a single route was enough to make dcrouter publicly hand
|
|
336
|
+
* out an RFC1918 address, with the `aa` flag, for a domain whose real
|
|
337
|
+
* delegation belonged to somebody else.
|
|
187
338
|
*/
|
|
188
|
-
public syncPrivateRouteOverrides(routesArg: IDcRouterRouteConfig[]): void {
|
|
189
|
-
const
|
|
339
|
+
public async syncPrivateRouteOverrides(routesArg: IDcRouterRouteConfig[]): Promise<void> {
|
|
340
|
+
const candidateHostnames = new Set<string>();
|
|
190
341
|
for (const route of routesArg) {
|
|
191
342
|
if (route.action?.type !== 'forward') continue;
|
|
192
343
|
const ingress = route.ingress;
|
|
@@ -211,10 +362,12 @@ export class DnsServerRuntime {
|
|
|
211
362
|
: [];
|
|
212
363
|
for (const domainArg of domainEntries) {
|
|
213
364
|
const hostname = this.normalizePrivateRouteHostname(domainArg);
|
|
214
|
-
if (hostname)
|
|
365
|
+
if (hostname) candidateHostnames.add(hostname);
|
|
215
366
|
}
|
|
216
367
|
}
|
|
217
368
|
|
|
369
|
+
const nextHostnames = await this.filterOwnedPrivateRouteHostnames(candidateHostnames);
|
|
370
|
+
|
|
218
371
|
const changed = nextHostnames.size !== this.privateRouteHostnames.size
|
|
219
372
|
|| [...nextHostnames].some((hostnameArg) => !this.privateRouteHostnames.has(hostnameArg));
|
|
220
373
|
this.privateRouteHostnames = nextHostnames;
|
|
@@ -227,6 +380,68 @@ export class DnsServerRuntime {
|
|
|
227
380
|
}
|
|
228
381
|
}
|
|
229
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Drop overlay hostnames whose zone ownership cannot be proven, logging each
|
|
385
|
+
* rejection at `error` with the reason. Fails closed: if the ownership inputs
|
|
386
|
+
* cannot be read, no hostname is served rather than all of them.
|
|
387
|
+
*/
|
|
388
|
+
private async filterOwnedPrivateRouteHostnames(
|
|
389
|
+
candidateHostnames: Set<string>,
|
|
390
|
+
): Promise<Set<string>> {
|
|
391
|
+
if (candidateHostnames.size === 0) {
|
|
392
|
+
return new Set<string>();
|
|
393
|
+
}
|
|
394
|
+
// The delegation-verified authority set, so a zone verified at runtime
|
|
395
|
+
// starts serving its overlay entries without a restart.
|
|
396
|
+
const authorityZones = this.dcRouterRef.dnsManager?.getAuthorityZones()
|
|
397
|
+
|| this.effectiveAuthorityZones();
|
|
398
|
+
let zones: IDomainOwnershipZone[];
|
|
399
|
+
try {
|
|
400
|
+
zones = (await this.dcRouterRef.dnsManager?.listOwnershipZones()) || [];
|
|
401
|
+
} catch (error: unknown) {
|
|
402
|
+
logger.log(
|
|
403
|
+
'error',
|
|
404
|
+
`DNS private-route overlay: cannot read managed zones (${(error as Error).message}); `
|
|
405
|
+
+ `refusing to serve ${candidateHostnames.size} private route hostname(s) rather than answering for unverified domains`,
|
|
406
|
+
{ zone: 'dns' },
|
|
407
|
+
);
|
|
408
|
+
return new Set<string>();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const ownedHostnames = new Set<string>();
|
|
412
|
+
for (const hostname of candidateHostnames) {
|
|
413
|
+
const ownership = resolveDomainOwnership({ fqdn: hostname, zones, authorityZones });
|
|
414
|
+
if (ownership.verified) {
|
|
415
|
+
ownedHostnames.add(hostname);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
logger.log(
|
|
419
|
+
'error',
|
|
420
|
+
`DNS private-route overlay: refusing to answer for '${hostname}' — ${ownership.detail}. `
|
|
421
|
+
+ 'dcrouter answers authoritatively on a public port, so serving this would claim a domain we cannot prove we own.',
|
|
422
|
+
{ zone: 'dns', ownershipReason: ownership.reason },
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
return ownedHostnames;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* SEQUENCED BEHIND A SMARTDNS RELEASE — this registration must become
|
|
430
|
+
* `{ authority: 'non-authoritative', owner: 'private-route-overlay' }`.
|
|
431
|
+
*
|
|
432
|
+
* The overlay is a split-horizon answer by intent: it hands out an internal
|
|
433
|
+
* address for a hostname whose zone we own, and it deliberately matches `*`
|
|
434
|
+
* so it can cover any owned hostname. Under the hardened smartdns authority
|
|
435
|
+
* model, a handler left at the default `authoritative` mode is *suppressed*
|
|
436
|
+
* for any name outside every configured `authoritativeZones` entry. Overlay
|
|
437
|
+
* hostnames proven by `provider-zone` are exactly that — owned, but not in
|
|
438
|
+
* the delegation-verified set — so on that smartdns they would stop being
|
|
439
|
+
* answered entirely, silently.
|
|
440
|
+
*
|
|
441
|
+
* The options parameter does not exist in the installed smartdns (7.12.1) and
|
|
442
|
+
* the change adding it is unreleased, so this cannot be written yet. It must
|
|
443
|
+
* land in the same change that takes the smartdns bump.
|
|
444
|
+
*/
|
|
230
445
|
private registerPrivateRouteHandler(
|
|
231
446
|
dnsServerArg: plugins.smartdns.dnsServerMod.DnsServer,
|
|
232
447
|
): void {
|
|
@@ -370,32 +585,38 @@ export class DnsServerRuntime {
|
|
|
370
585
|
|
|
371
586
|
private async validateConfiguration(): Promise<void> {
|
|
372
587
|
const options = this.dcRouterRef.options;
|
|
373
|
-
if (!options.dnsNsDomains
|
|
588
|
+
if (!options.dnsNsDomains) {
|
|
374
589
|
return;
|
|
375
590
|
}
|
|
591
|
+
const authorityZones = this.effectiveAuthorityZones();
|
|
376
592
|
|
|
377
593
|
logger.log('info', 'Validating DNS configuration...');
|
|
594
|
+
const covered = (nameArg: string): boolean => authorityZones.some((zone) =>
|
|
595
|
+
nameArg === zone || nameArg.endsWith(`.${zone}`));
|
|
378
596
|
|
|
379
|
-
//
|
|
597
|
+
// Email domains served from the embedded DNS need the zone to be ours.
|
|
380
598
|
if (options.emailConfig?.domains) {
|
|
381
599
|
for (const domainConfig of options.emailConfig.domains) {
|
|
382
|
-
if (domainConfig.dnsMode === 'internal-dns' &&
|
|
383
|
-
|
|
384
|
-
|
|
600
|
+
if (domainConfig.dnsMode === 'internal-dns' && !covered(domainConfig.domain.toLowerCase())) {
|
|
601
|
+
logger.log(
|
|
602
|
+
'warn',
|
|
603
|
+
`Email domain '${domainConfig.domain}' uses internal-dns but its zone is not delegation-verified, `
|
|
604
|
+
+ 'so dcrouter will REFUSE queries for it. Verify the zone (dns-authority:write) to serve it.',
|
|
605
|
+
);
|
|
385
606
|
}
|
|
386
607
|
}
|
|
387
608
|
}
|
|
388
609
|
|
|
389
|
-
// Validate
|
|
610
|
+
// Validate caller-provided DNS records fall inside a zone we may answer for
|
|
390
611
|
if (options.dnsRecords) {
|
|
391
612
|
for (const record of options.dnsRecords) {
|
|
392
|
-
const recordDomain = this.extractDomain(record.name);
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
613
|
+
const recordDomain = this.extractDomain(record.name).toLowerCase();
|
|
614
|
+
if (!covered(recordDomain)) {
|
|
615
|
+
logger.log(
|
|
616
|
+
'warn',
|
|
617
|
+
`DNS record for '${record.name}' is outside every delegation-verified zone `
|
|
618
|
+
+ `[${authorityZones.join(', ') || 'none'}] and will be REFUSED`,
|
|
619
|
+
);
|
|
399
620
|
}
|
|
400
621
|
}
|
|
401
622
|
}
|
|
@@ -486,11 +707,24 @@ export class DnsServerRuntime {
|
|
|
486
707
|
);
|
|
487
708
|
}
|
|
488
709
|
|
|
710
|
+
/**
|
|
711
|
+
* Nameserver A records (glue) only.
|
|
712
|
+
*
|
|
713
|
+
* Generated apex NS records used to be emitted here, one static set per
|
|
714
|
+
* bootstrap `dnsScopes` entry, registered once at setup and never revisited.
|
|
715
|
+
* Under database-sourced authority that is wrong twice over: a zone verified
|
|
716
|
+
* after startup would never get them, and a zone whose authority was revoked
|
|
717
|
+
* would keep them until an unrelated restart. `DnsManager` owns generated apex
|
|
718
|
+
* NS for every authoritative zone now — it already did for every zone that was
|
|
719
|
+
* not in `dnsScopes` — and reconciles them in-process in both directions.
|
|
720
|
+
* A verified zone with no `DomainDoc` behind it therefore serves no apex NS,
|
|
721
|
+
* which the `verified-but-unhosted` drift finding reports at `error`.
|
|
722
|
+
*/
|
|
489
723
|
private async generateAuthoritativeRecords(): Promise<TDnsRecordSeed[]> {
|
|
490
724
|
const options = this.dcRouterRef.options;
|
|
491
725
|
const records: TDnsRecordSeed[] = [];
|
|
492
726
|
|
|
493
|
-
if (!options.dnsNsDomains
|
|
727
|
+
if (!options.dnsNsDomains) {
|
|
494
728
|
return records;
|
|
495
729
|
}
|
|
496
730
|
|
|
@@ -544,23 +778,8 @@ export class DnsServerRuntime {
|
|
|
544
778
|
logger.log('info', `Generated A records for ${options.dnsNsDomains.length} nameservers`);
|
|
545
779
|
}
|
|
546
780
|
|
|
547
|
-
//
|
|
548
|
-
|
|
549
|
-
// Add NS records for all nameservers
|
|
550
|
-
for (const nsDomain of options.dnsNsDomains) {
|
|
551
|
-
records.push({
|
|
552
|
-
name: domain,
|
|
553
|
-
type: 'NS',
|
|
554
|
-
value: nsDomain,
|
|
555
|
-
ttl: 3600
|
|
556
|
-
});
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// SOA records are now automatically generated by smartdns DnsServer
|
|
560
|
-
// with the primaryNameserver configuration option
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
logger.log('info', `Generated ${records.length} total records (A + NS) for ${options.dnsScopes.length} domains`);
|
|
781
|
+
// Apex NS records are owned by DnsManager (see the doc comment above), and
|
|
782
|
+
// SOA is synthesized by smartdns from `primaryNameserver`.
|
|
564
783
|
return records;
|
|
565
784
|
}
|
|
566
785
|
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { PlatformError, type IErrorContext } from '../errors/base.errors.js';
|
|
2
|
+
import {
|
|
3
|
+
DCR_DOMAIN_OWNERSHIP_UNVERIFIED,
|
|
4
|
+
ErrorCategory,
|
|
5
|
+
ErrorRecoverability,
|
|
6
|
+
ErrorSeverity,
|
|
7
|
+
} from '../errors/error.codes.js';
|
|
8
|
+
import type { TDomainSource } from '../../ts_interfaces/data/domain.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Domain ownership verification.
|
|
12
|
+
*
|
|
13
|
+
* dcrouter may only take two kinds of action on a hostname if we can prove the
|
|
14
|
+
* zone is ours: request an ACME certificate for it, and answer DNS queries for
|
|
15
|
+
* it authoritatively. Both used to be reachable without any ownership record at
|
|
16
|
+
* all, which produced two production failures:
|
|
17
|
+
*
|
|
18
|
+
* - Routes with `tls.certificate === 'auto'` were created for zones that had no
|
|
19
|
+
* `DomainDoc`. DNS-01 could never place the challenge TXT, so the per-domain
|
|
20
|
+
* provisioning budget was consumed against a cause no retry can fix and the
|
|
21
|
+
* certificates silently expired.
|
|
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.
|
|
28
|
+
*
|
|
29
|
+
* There are exactly two proofs available in-process, neither of which an ops-API
|
|
30
|
+
* caller can forge:
|
|
31
|
+
*
|
|
32
|
+
* - `provider-zone`: the zone has a `DomainDoc` with `source === 'provider'` and
|
|
33
|
+
* a `providerId`. It only gets there through `importDomainsFromProvider()`,
|
|
34
|
+
* which requires the zone to be listed by a credentialed provider account.
|
|
35
|
+
* - `delegation-verified-zone`: the zone is in the DNS authority set, which a
|
|
36
|
+
* zone only enters by having its public NS records observed naming our
|
|
37
|
+
* nameservers. An ops-API caller cannot repoint somebody else's delegation,
|
|
38
|
+
* so writing the record is not the same as manufacturing the proof.
|
|
39
|
+
*
|
|
40
|
+
* This used to read `options.dnsScopes` instead — deployment configuration,
|
|
41
|
+
* trusted because only a redeploy could change it. That trust was real but the
|
|
42
|
+
* cost was a second, un-reconcilable representation of DNS authority, so it is
|
|
43
|
+
* gone: the authority set now comes from the database and carries its evidence.
|
|
44
|
+
*
|
|
45
|
+
* Anything else is unverified and must fail closed.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
export type TDomainOwnershipMethod = 'provider-zone' | 'delegation-verified-zone';
|
|
49
|
+
|
|
50
|
+
export type TDomainOwnershipFailure =
|
|
51
|
+
/** No DomainDoc covers the hostname at all. */
|
|
52
|
+
| 'no-managed-domain'
|
|
53
|
+
/** Provider-sourced DomainDoc without a providerId — the credentialed link is gone. */
|
|
54
|
+
| 'provider-link-missing'
|
|
55
|
+
/** dcrouter-hosted DomainDoc outside every verified zone: self-asserted authority. */
|
|
56
|
+
| 'unverified-dcrouter-zone'
|
|
57
|
+
/** The hostname is not a usable FQDN (wildcard-only, empty, malformed labels). */
|
|
58
|
+
| 'invalid-hostname';
|
|
59
|
+
|
|
60
|
+
export interface IDomainOwnershipZone {
|
|
61
|
+
name: string;
|
|
62
|
+
source: TDomainSource;
|
|
63
|
+
providerId?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface IDomainOwnershipVerified {
|
|
67
|
+
verified: true;
|
|
68
|
+
fqdn: string;
|
|
69
|
+
zone: string;
|
|
70
|
+
method: TDomainOwnershipMethod;
|
|
71
|
+
evidence: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface IDomainOwnershipUnverified {
|
|
75
|
+
verified: false;
|
|
76
|
+
fqdn: string;
|
|
77
|
+
zone?: string;
|
|
78
|
+
reason: TDomainOwnershipFailure;
|
|
79
|
+
detail: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type TDomainOwnership = IDomainOwnershipVerified | IDomainOwnershipUnverified;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Normalize a route/record hostname to the FQDN whose ownership must be proven.
|
|
86
|
+
*
|
|
87
|
+
* A wildcard is proven by the zone beneath it, so a single leading `*` is
|
|
88
|
+
* stripped in both forms SmartProxy accepts for certificate provisioning:
|
|
89
|
+
* `*.example.com` and the routing-glob `*example.com` (see
|
|
90
|
+
* `normalizeDomainsForCertProvisioning` in smartproxy). A bare `*` normalizes to
|
|
91
|
+
* nothing and is rejected — no certificate can be issued for it, so it must fail
|
|
92
|
+
* loudly rather than reach ACME.
|
|
93
|
+
*
|
|
94
|
+
* Returns undefined for anything that is not a usable single hostname.
|
|
95
|
+
*/
|
|
96
|
+
export const normalizeOwnershipHostname = (hostnameArg: string): string | undefined => {
|
|
97
|
+
const hostname = hostnameArg
|
|
98
|
+
.trim()
|
|
99
|
+
.toLowerCase()
|
|
100
|
+
.replace(/\.$/, '')
|
|
101
|
+
.replace(/^\*\.?/, '');
|
|
102
|
+
if (!hostname || hostname.length > 253) return undefined;
|
|
103
|
+
if (hostname.includes('*') || hostname.includes(',') || hostname.includes(' ')) return undefined;
|
|
104
|
+
const labels = hostname.split('.');
|
|
105
|
+
if (labels.length < 2) return undefined;
|
|
106
|
+
if (labels.some((labelArg) => !labelArg || labelArg.length > 63
|
|
107
|
+
|| !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(labelArg))) return undefined;
|
|
108
|
+
return hostname;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const normalizeZoneName = (zoneArg: string): string =>
|
|
112
|
+
zoneArg.trim().toLowerCase().replace(/\.$/, '');
|
|
113
|
+
|
|
114
|
+
const coversFqdn = (zone: string, fqdn: string): boolean =>
|
|
115
|
+
Boolean(zone) && (fqdn === zone || fqdn.endsWith(`.${zone}`));
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The authority zone covering `fqdn`, if any (the zone itself or a subzone of
|
|
119
|
+
* one). Subzones count: being authoritative for `example.com` means
|
|
120
|
+
* `internal.example.com` is ours too.
|
|
121
|
+
*/
|
|
122
|
+
export const findCoveringAuthorityZone = (
|
|
123
|
+
fqdnArg: string,
|
|
124
|
+
authorityZones?: string[],
|
|
125
|
+
): string | undefined => {
|
|
126
|
+
const fqdn = normalizeZoneName(fqdnArg);
|
|
127
|
+
return (authorityZones || [])
|
|
128
|
+
.map(normalizeZoneName)
|
|
129
|
+
.filter(Boolean)
|
|
130
|
+
.sort((a, b) => b.length - a.length)
|
|
131
|
+
.find((zone) => coversFqdn(zone, fqdn));
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolve whether we can prove ownership of `fqdn`. Pure: callers pass the zone
|
|
136
|
+
* set and the authority set so this stays testable and does exactly one DB
|
|
137
|
+
* read per audit pass rather than one per hostname.
|
|
138
|
+
*/
|
|
139
|
+
export const resolveDomainOwnership = (args: {
|
|
140
|
+
fqdn: string;
|
|
141
|
+
zones: IDomainOwnershipZone[];
|
|
142
|
+
authorityZones?: string[];
|
|
143
|
+
}): TDomainOwnership => {
|
|
144
|
+
const fqdn = normalizeOwnershipHostname(args.fqdn);
|
|
145
|
+
if (!fqdn) {
|
|
146
|
+
return {
|
|
147
|
+
verified: false,
|
|
148
|
+
fqdn: args.fqdn,
|
|
149
|
+
reason: 'invalid-hostname',
|
|
150
|
+
detail: `'${args.fqdn}' is not a usable hostname, so its ownership cannot be established`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// A delegation-verified zone is proof on its own: the public NS records were
|
|
155
|
+
// observed naming our nameservers, which no ops-API caller can arrange.
|
|
156
|
+
const coveringZone = findCoveringAuthorityZone(fqdn, args.authorityZones);
|
|
157
|
+
if (coveringZone) {
|
|
158
|
+
return {
|
|
159
|
+
verified: true,
|
|
160
|
+
fqdn,
|
|
161
|
+
zone: coveringZone,
|
|
162
|
+
method: 'delegation-verified-zone',
|
|
163
|
+
evidence: `dns-authority:${coveringZone}`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Otherwise the proof must come from a credentialed provider zone. Most
|
|
168
|
+
// specific zone first, but every covering zone is a candidate: owning
|
|
169
|
+
// example.com still proves sub.example.com even when a more specific
|
|
170
|
+
// dcrouter-hosted doc for the subzone exists. The first verified candidate
|
|
171
|
+
// wins; otherwise the most specific failure is reported.
|
|
172
|
+
const candidates = args.zones
|
|
173
|
+
.map((zoneArg) => ({ ...zoneArg, name: normalizeZoneName(zoneArg.name) }))
|
|
174
|
+
.filter((zoneArg) => coversFqdn(zoneArg.name, fqdn))
|
|
175
|
+
.sort((a, b) => b.name.length - a.name.length);
|
|
176
|
+
|
|
177
|
+
if (candidates.length === 0) {
|
|
178
|
+
return {
|
|
179
|
+
verified: false,
|
|
180
|
+
fqdn,
|
|
181
|
+
reason: 'no-managed-domain',
|
|
182
|
+
detail: `no managed domain and no delegation-verified zone covers ${fqdn}; import the zone from a DNS provider, or point its NS records at our nameservers and verify it, before requesting certificates or serving DNS for it`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const failures: IDomainOwnershipUnverified[] = [];
|
|
187
|
+
for (const zone of candidates) {
|
|
188
|
+
if (zone.source === 'provider') {
|
|
189
|
+
if (!zone.providerId) {
|
|
190
|
+
failures.push({
|
|
191
|
+
verified: false,
|
|
192
|
+
fqdn,
|
|
193
|
+
zone: zone.name,
|
|
194
|
+
reason: 'provider-link-missing',
|
|
195
|
+
detail: `managed domain ${zone.name} is provider-sourced but has no providerId, so the credentialed zone listing that proved ownership is gone`,
|
|
196
|
+
});
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
verified: true,
|
|
201
|
+
fqdn,
|
|
202
|
+
zone: zone.name,
|
|
203
|
+
method: 'provider-zone',
|
|
204
|
+
evidence: `provider:${zone.providerId}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
failures.push({
|
|
209
|
+
verified: false,
|
|
210
|
+
fqdn,
|
|
211
|
+
zone: zone.name,
|
|
212
|
+
reason: 'unverified-dcrouter-zone',
|
|
213
|
+
detail: `managed domain ${zone.name} is dcrouter-hosted but is not in the DNS authority set, so nothing proves the zone is delegated to us; verify its delegation (dns-authority:write) or import it from the DNS provider that holds it`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return failures[0];
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export const buildDomainOwnershipMessage = (
|
|
221
|
+
ownership: IDomainOwnershipUnverified,
|
|
222
|
+
operation: string,
|
|
223
|
+
): string =>
|
|
224
|
+
`${operation} refused for '${ownership.fqdn}': domain ownership is unverified (${ownership.reason}) — ${ownership.detail}`;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Thrown wherever an unverified domain would otherwise gain a certificate
|
|
228
|
+
* requirement or authoritative DNS. HIGH severity so PlatformError's automatic
|
|
229
|
+
* log lands at `error` (this must never be a debuggable-later warning), and
|
|
230
|
+
* NON_RECOVERABLE by construction: no retry can turn an unowned domain into an
|
|
231
|
+
* owned one, so retry layers must classify it as permanent.
|
|
232
|
+
*/
|
|
233
|
+
export class DomainOwnershipError extends PlatformError {
|
|
234
|
+
public readonly ownership: IDomainOwnershipUnverified;
|
|
235
|
+
|
|
236
|
+
constructor(
|
|
237
|
+
ownership: IDomainOwnershipUnverified,
|
|
238
|
+
operation: string,
|
|
239
|
+
component: string,
|
|
240
|
+
context: IErrorContext = {},
|
|
241
|
+
) {
|
|
242
|
+
super(
|
|
243
|
+
buildDomainOwnershipMessage(ownership, operation),
|
|
244
|
+
DCR_DOMAIN_OWNERSHIP_UNVERIFIED,
|
|
245
|
+
ErrorSeverity.HIGH,
|
|
246
|
+
ErrorCategory.CONFIGURATION,
|
|
247
|
+
ErrorRecoverability.NON_RECOVERABLE,
|
|
248
|
+
{
|
|
249
|
+
component,
|
|
250
|
+
operation,
|
|
251
|
+
userMessage: `Ownership of '${ownership.fqdn}' is not verified: ${ownership.detail}`,
|
|
252
|
+
...context,
|
|
253
|
+
data: {
|
|
254
|
+
fqdn: ownership.fqdn,
|
|
255
|
+
zone: ownership.zone,
|
|
256
|
+
reason: ownership.reason,
|
|
257
|
+
...context.data,
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
this.ownership = ownership;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
protected createWithContext(context: IErrorContext): PlatformError {
|
|
265
|
+
return new DomainOwnershipError(
|
|
266
|
+
this.ownership,
|
|
267
|
+
this.context.operation || 'operation',
|
|
268
|
+
this.context.component || 'domain-ownership',
|
|
269
|
+
context,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
package/ts/dns/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export * from './manager.dns.js';
|
|
2
|
+
export * from './domain-ownership.js';
|
|
3
|
+
export * from './manager.dns-authority.js';
|
|
2
4
|
export * from './providers/index.js';
|
|
3
5
|
export * from './classes.dns-server-runtime.js';
|
|
4
6
|
export * from './classes.gateway-route-dns-reconciler.js';
|