@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
|
@@ -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';
|