@serve.zone/dcrouter 34.0.0 → 34.1.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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/dns/classes.dns-server-runtime.d.ts +12 -0
- package/dist_ts/dns/classes.dns-server-runtime.js +19 -3
- package/dist_ts/dns/manager.dns-authority.d.ts +49 -10
- package/dist_ts/dns/manager.dns-authority.js +351 -63
- package/dist_ts_interfaces/data/dns-authority.d.ts +64 -7
- package/dist_ts_interfaces/data/dns-authority.js +10 -5
- package/dist_ts_interfaces/requests/dns-authority.d.ts +7 -5
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +4 -2
- package/readme.md +59 -10
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/dns/classes.dns-server-runtime.ts +21 -2
- package/ts/dns/manager.dns-authority.ts +429 -66
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
IDnsAuthorityDrift,
|
|
6
6
|
IDnsAuthoritySettings,
|
|
7
7
|
IDnsAuthorityZone,
|
|
8
|
+
IDnsDelegationParentAnswer,
|
|
8
9
|
IDnsDelegationProbe,
|
|
9
10
|
TDnsAuthorityState,
|
|
10
11
|
} from '../../ts_interfaces/data/dns-authority.js';
|
|
@@ -40,6 +41,227 @@ const isUsableZoneName = (zoneArg: string): boolean => {
|
|
|
40
41
|
&& /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(labelArg));
|
|
41
42
|
};
|
|
42
43
|
|
|
44
|
+
/** What the delegation probe uses of a smartdns client: DoH lookups, direct server queries, cleanup. */
|
|
45
|
+
type TDelegationProbeClient = Pick<
|
|
46
|
+
plugins.smartdns.dnsClientMod.Smartdns,
|
|
47
|
+
'queryRecords' | 'queryServer' | 'terminate'
|
|
48
|
+
>;
|
|
49
|
+
type TProbeDohResult = Awaited<ReturnType<TDelegationProbeClient['queryRecords']>>;
|
|
50
|
+
type TProbeServerResult = Awaited<ReturnType<TDelegationProbeClient['queryServer']>>;
|
|
51
|
+
|
|
52
|
+
/** Timeout of each DNS-over-HTTPS lookup of a probe (D). */
|
|
53
|
+
const PROBE_DOH_TIMEOUT_MS = 5_000;
|
|
54
|
+
/**
|
|
55
|
+
* Timeout of each attempt of a parent server query (T). A truncated UDP answer
|
|
56
|
+
* is asked again over TCP with its own timeout, so one server costs at most 2·T.
|
|
57
|
+
*/
|
|
58
|
+
const PARENT_QUERY_TIMEOUT_MS = 2_000;
|
|
59
|
+
/**
|
|
60
|
+
* No probe phase starts after this deadline. The phase in flight may still run
|
|
61
|
+
* to its own bound, so a probe ends within the deadline + max(D, 2·T) = 35 s,
|
|
62
|
+
* which is also the longest a claim holds the DNS mutation lock for its probe.
|
|
63
|
+
*/
|
|
64
|
+
const PROBE_DEADLINE_MS = 30_000;
|
|
65
|
+
/** Proper suffixes of the zone tried as its parent zone, nearest first. */
|
|
66
|
+
const PARENT_WALK_LIMIT = 8;
|
|
67
|
+
/** Parent nameserver names whose addresses are looked up. */
|
|
68
|
+
const PARENT_NAME_LIMIT = 8;
|
|
69
|
+
/** Parent servers asked for the referral. */
|
|
70
|
+
const PARENT_SERVER_LIMIT = 4;
|
|
71
|
+
/** Conclusive parent answers needed, from as many different parent nameserver names. */
|
|
72
|
+
const PARENT_AGREEMENT_MINIMUM = 2;
|
|
73
|
+
|
|
74
|
+
/** One parent server address and the parent nameserver name it was resolved from. */
|
|
75
|
+
interface IParentServer {
|
|
76
|
+
address: string;
|
|
77
|
+
nameserver: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type TParentZoneLookup =
|
|
81
|
+
| { status: 'found'; parentZone: string; nameservers: string[] }
|
|
82
|
+
| { status: 'failed'; detail: string };
|
|
83
|
+
|
|
84
|
+
type TParentServerSelection =
|
|
85
|
+
| { status: 'selected'; servers: IParentServer[] }
|
|
86
|
+
| { status: 'failed'; detail: string };
|
|
87
|
+
|
|
88
|
+
type TParentAnswerClass = Omit<IDnsDelegationParentAnswer, 'server' | 'nameserver'>;
|
|
89
|
+
|
|
90
|
+
const describeError = (errorArg: unknown): string =>
|
|
91
|
+
errorArg instanceof Error ? errorArg.message : String(errorArg);
|
|
92
|
+
|
|
93
|
+
const describeDohError = (resultArg: TProbeDohResult): string =>
|
|
94
|
+
resultArg.status === 'error'
|
|
95
|
+
? `${resultArg.error.kind}/${resultArg.error.code}: ${resultArg.error.message}`
|
|
96
|
+
: resultArg.status;
|
|
97
|
+
|
|
98
|
+
/** The address the probe asks, as `ip:port`, the way smartdns reports servers. */
|
|
99
|
+
const formatParentServer = (addressArg: string): string =>
|
|
100
|
+
plugins.net.isIPv6(addressArg) ? `[${addressArg}]:53` : `${addressArg}:53`;
|
|
101
|
+
|
|
102
|
+
const rcodeNames: Record<number, string> = {
|
|
103
|
+
0: 'NOERROR',
|
|
104
|
+
1: 'FORMERR',
|
|
105
|
+
2: 'SERVFAIL',
|
|
106
|
+
3: 'NXDOMAIN',
|
|
107
|
+
4: 'NOTIMP',
|
|
108
|
+
5: 'REFUSED',
|
|
109
|
+
};
|
|
110
|
+
const describeRcode = (rcodeArg: number): string => rcodeNames[rcodeArg] ?? `RCODE ${rcodeArg}`;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Classify one parent server's answer to the non-recursive NS query for
|
|
114
|
+
* `zoneArg`. Only two shapes are conclusive, and everything else is not:
|
|
115
|
+
*
|
|
116
|
+
* - a referral: RCODE 0, AA=0, an empty answer section, at least one authority
|
|
117
|
+
* NS and every authority NS owned by the zone, and an OPT record echoing DO=1.
|
|
118
|
+
* Without that echo the server may simply not have sent the DS, so a referral
|
|
119
|
+
* without one would not prove that there is none.
|
|
120
|
+
* - a negative: AA=1, NXDOMAIN or NODATA, an empty answer section, and SOA
|
|
121
|
+
* records in the authority section that are all owned by the parent zone.
|
|
122
|
+
*
|
|
123
|
+
* Any other AA=1 answer is inconclusive, an AA=1 referral included: a server
|
|
124
|
+
* that hosts both the parent and the child answers for the child, and such an
|
|
125
|
+
* answer says nothing about the delegation.
|
|
126
|
+
*/
|
|
127
|
+
const classifyParentAnswer = (
|
|
128
|
+
zoneArg: string,
|
|
129
|
+
parentZoneArg: string,
|
|
130
|
+
resultArg: TProbeServerResult,
|
|
131
|
+
): TParentAnswerClass => {
|
|
132
|
+
const inconclusive = (detailArg: string): TParentAnswerClass => ({ outcome: 'inconclusive', detail: detailArg });
|
|
133
|
+
if (resultArg.status === 'error') {
|
|
134
|
+
return inconclusive(`no usable answer (${resultArg.error.kind}/${resultArg.error.code}): ${resultArg.error.message}`);
|
|
135
|
+
}
|
|
136
|
+
if (resultArg.flags.tc) return inconclusive('the answer is truncated');
|
|
137
|
+
if (resultArg.flags.aa) {
|
|
138
|
+
const soaOwners = resultArg.authorities
|
|
139
|
+
.filter((recordArg) => recordArg.type === 'SOA')
|
|
140
|
+
.map((recordArg) => normalizeZone(recordArg.name));
|
|
141
|
+
const isNegative = (resultArg.rcode === 0 || resultArg.rcode === 3)
|
|
142
|
+
&& resultArg.answers.length === 0
|
|
143
|
+
&& soaOwners.length > 0
|
|
144
|
+
&& soaOwners.every((ownerArg) => ownerArg === parentZoneArg);
|
|
145
|
+
if (isNegative) {
|
|
146
|
+
return {
|
|
147
|
+
outcome: 'negative',
|
|
148
|
+
detail: `${describeRcode(resultArg.rcode)} from ${parentZoneArg}: no delegation for ${zoneArg}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (resultArg.answers.some((recordArg) => recordArg.type === 'NS' && normalizeZone(recordArg.name) === zoneArg)) {
|
|
152
|
+
return inconclusive(`authoritative for ${zoneArg} itself (AA=1 NS answer), not a referral from ${parentZoneArg}`);
|
|
153
|
+
}
|
|
154
|
+
return inconclusive(
|
|
155
|
+
`authoritative ${describeRcode(resultArg.rcode)} that is neither a referral nor a negative from ${parentZoneArg}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (resultArg.rcode !== 0) return inconclusive(`${describeRcode(resultArg.rcode)} without AA`);
|
|
159
|
+
if (resultArg.answers.length > 0) return inconclusive('a non-authoritative answer section, not a referral');
|
|
160
|
+
const referralRecords = resultArg.authorities.filter((recordArg) => recordArg.type === 'NS');
|
|
161
|
+
if (referralRecords.length === 0) return inconclusive('no NS records in the authority section');
|
|
162
|
+
const foreignOwner = referralRecords.find((recordArg) => normalizeZone(recordArg.name) !== zoneArg);
|
|
163
|
+
if (foreignOwner) {
|
|
164
|
+
return inconclusive(`a referral for ${normalizeZone(foreignOwner.name) || '.'}, not for ${zoneArg}`);
|
|
165
|
+
}
|
|
166
|
+
if (resultArg.edns?.dnssecOk !== true) {
|
|
167
|
+
return inconclusive('the answer does not echo DO=1, so the absence of a DS is not proven');
|
|
168
|
+
}
|
|
169
|
+
const nameservers = [...new Set(
|
|
170
|
+
referralRecords.map((recordArg) => normalizeNameserver(recordArg.value)).filter(Boolean),
|
|
171
|
+
)].sort();
|
|
172
|
+
if (nameservers.length === 0) return inconclusive('referral NS records without a nameserver name');
|
|
173
|
+
const dsPresent = resultArg.authorities.some(
|
|
174
|
+
(recordArg) => recordArg.type === 'DS' && normalizeZone(recordArg.name) === zoneArg,
|
|
175
|
+
);
|
|
176
|
+
return {
|
|
177
|
+
outcome: 'referral',
|
|
178
|
+
nameservers,
|
|
179
|
+
dsPresent,
|
|
180
|
+
detail: `referral to ${nameservers.join(', ')}${dsPresent ? ' with a DS' : ' without a DS'}`,
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** What "identical" compares: the normalised NS set and DS presence, nothing else. */
|
|
185
|
+
const parentAnswerKey = (answerArg: IDnsDelegationParentAnswer): string =>
|
|
186
|
+
answerArg.outcome === 'referral'
|
|
187
|
+
? `referral:${(answerArg.nameservers ?? []).join(',')}|ds:${answerArg.dsPresent === true}`
|
|
188
|
+
: answerArg.outcome;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Turn the parent servers' classified answers into a verdict: at least two
|
|
192
|
+
* conclusive answers, through at least two different parent nameserver names,
|
|
193
|
+
* all identical — any disagreement is undeterminable.
|
|
194
|
+
*/
|
|
195
|
+
const aggregateParentAnswers = (
|
|
196
|
+
zoneArg: string,
|
|
197
|
+
parentZoneArg: string,
|
|
198
|
+
expectedNameserversArg: string[],
|
|
199
|
+
answersArg: IDnsDelegationParentAnswer[],
|
|
200
|
+
): IDnsDelegationProbe => {
|
|
201
|
+
const base = {
|
|
202
|
+
zone: zoneArg,
|
|
203
|
+
expectedNameservers: expectedNameserversArg,
|
|
204
|
+
parentZone: parentZoneArg,
|
|
205
|
+
parentAnswers: answersArg,
|
|
206
|
+
};
|
|
207
|
+
const undeterminable = (detailArg: string): IDnsDelegationProbe => ({
|
|
208
|
+
...base,
|
|
209
|
+
verdict: 'undeterminable',
|
|
210
|
+
observedNameservers: [],
|
|
211
|
+
detail: detailArg,
|
|
212
|
+
});
|
|
213
|
+
const conclusive = answersArg.filter((answerArg) => answerArg.outcome !== 'inconclusive');
|
|
214
|
+
if (conclusive.length < PARENT_AGREEMENT_MINIMUM) {
|
|
215
|
+
return undeterminable(
|
|
216
|
+
`${conclusive.length} of ${answersArg.length} parent server(s) of ${parentZoneArg} gave a conclusive answer `
|
|
217
|
+
+ `for ${zoneArg}; at least ${PARENT_AGREEMENT_MINIMUM} identical ones are needed`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (new Set(conclusive.map(parentAnswerKey)).size > 1) {
|
|
221
|
+
return undeterminable(
|
|
222
|
+
`the parent servers of ${parentZoneArg} disagree about ${zoneArg}: `
|
|
223
|
+
+ conclusive.map((answerArg) => `${answerArg.server} (${answerArg.nameserver}) ${answerArg.detail}`).join('; '),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const agreeingNames = [...new Set(conclusive.map((answerArg) => answerArg.nameserver))];
|
|
227
|
+
if (agreeingNames.length < PARENT_AGREEMENT_MINIMUM) {
|
|
228
|
+
return undeterminable(
|
|
229
|
+
`the conclusive answers for ${zoneArg} all came through one parent nameserver name (${agreeingNames.join(', ')}); `
|
|
230
|
+
+ `at least ${PARENT_AGREEMENT_MINIMUM} different names are needed`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
const agreed = conclusive[0];
|
|
234
|
+
if (agreed.outcome === 'negative') {
|
|
235
|
+
return {
|
|
236
|
+
...base,
|
|
237
|
+
verdict: 'not-delegated',
|
|
238
|
+
observedNameservers: [],
|
|
239
|
+
detail: `${parentZoneArg} has no delegation for ${zoneArg}`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
const delegatedTo = agreed.nameservers ?? [];
|
|
243
|
+
const dsPresent = agreed.dsPresent === true;
|
|
244
|
+
const ours = delegatedTo.filter((nameserverArg) => expectedNameserversArg.includes(nameserverArg));
|
|
245
|
+
if (ours.length === 0) {
|
|
246
|
+
return {
|
|
247
|
+
...base,
|
|
248
|
+
verdict: 'not-delegated',
|
|
249
|
+
observedNameservers: delegatedTo,
|
|
250
|
+
dsPresent,
|
|
251
|
+
detail: `${parentZoneArg} delegates ${zoneArg} to ${delegatedTo.join(', ')}, which does not include any of our `
|
|
252
|
+
+ `nameservers (${expectedNameserversArg.join(', ')})`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
...base,
|
|
257
|
+
verdict: 'delegated',
|
|
258
|
+
observedNameservers: delegatedTo,
|
|
259
|
+
dsPresent,
|
|
260
|
+
detail: `${parentZoneArg} delegates ${zoneArg} to ${delegatedTo.join(', ')}`
|
|
261
|
+
+ (dsPresent ? `; ${parentZoneArg} publishes a DS for ${zoneArg}` : ''),
|
|
262
|
+
};
|
|
263
|
+
};
|
|
264
|
+
|
|
43
265
|
/**
|
|
44
266
|
* DnsAuthorityManager — owns which zones dcrouter may answer for
|
|
45
267
|
* authoritatively, and proves that claim rather than accepting it.
|
|
@@ -54,9 +276,9 @@ const isUsableZoneName = (zoneArg: string): boolean => {
|
|
|
54
276
|
* any domain and manufacture that proof — the identical self-assertion vector
|
|
55
277
|
* that `createDcrouterDomain` used to have.
|
|
56
278
|
*
|
|
57
|
-
* So a zone earns authority by delegation instead: its
|
|
58
|
-
* name our `dnsNsDomains`. An ops-API caller cannot fake that without
|
|
59
|
-
* controlling the domain, so the proof stays unforgeable while becoming
|
|
279
|
+
* So a zone earns authority by delegation instead: its delegation in the parent
|
|
280
|
+
* zone must name our `dnsNsDomains`. An ops-API caller cannot fake that without
|
|
281
|
+
* actually controlling the domain, so the proof stays unforgeable while becoming
|
|
60
282
|
* mutable at runtime. It is also the comparison that was missing — claimed
|
|
61
283
|
* authority versus real delegation, checked by the mechanism itself rather than
|
|
62
284
|
* by an audit bolted on afterwards.
|
|
@@ -101,7 +323,7 @@ export class DnsAuthorityManager {
|
|
|
101
323
|
private reconciler?: TDnsAuthorityReconciler;
|
|
102
324
|
private stopped = false;
|
|
103
325
|
private activeProbes = new Map<string, Promise<IDnsDelegationProbe>>();
|
|
104
|
-
private ownedProbeClients = new Set<
|
|
326
|
+
private ownedProbeClients = new Set<TDelegationProbeClient>();
|
|
105
327
|
|
|
106
328
|
constructor(
|
|
107
329
|
private getExpectedNameservers: () => string[],
|
|
@@ -223,18 +445,43 @@ export class DnsAuthorityManager {
|
|
|
223
445
|
// ==========================================================================
|
|
224
446
|
|
|
225
447
|
/**
|
|
226
|
-
* Ask the
|
|
448
|
+
* Ask the parent zone's own servers whether `zone` is delegated to our
|
|
449
|
+
* nameservers.
|
|
450
|
+
*
|
|
451
|
+
* A recursive lookup of the zone cannot prove this. For a zone delegated to
|
|
452
|
+
* us but not claimed yet, a public resolver follows the delegation to our
|
|
453
|
+
* servers, which REFUSE the zone because it is not claimed, so the resolver
|
|
454
|
+
* answers SERVFAIL and the zone could never be claimed. The delegation itself
|
|
455
|
+
* lives in the parent zone, so the probe reads it there:
|
|
227
456
|
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
457
|
+
* 1. Find the parent zone over DNS-over-HTTPS: the nearest proper suffix of
|
|
458
|
+
* the zone with an NS RRset (at most 8 levels). The zone itself, and every
|
|
459
|
+
* name below it, is never looked up over DoH: that is the circular query.
|
|
460
|
+
* A failed lookup at any level is undeterminable; a level is never
|
|
461
|
+
* skipped, which could pick a grandparent.
|
|
462
|
+
* 2. Look up A and AAAA of up to 8 parent nameserver names (sorted, names at
|
|
463
|
+
* or below the zone dropped) over DoH, and pick up to 4 distinct addresses:
|
|
464
|
+
* one per name first, IPv4 before IPv6, then further addresses round by
|
|
465
|
+
* round. Fewer than 2 names with an address is undeterminable.
|
|
466
|
+
* 3. Ask each picked server, in parallel, for the zone's NS with RD=0 and
|
|
467
|
+
* DO=1, over UDP (TCP when truncated). A signed parent then sends the DS
|
|
468
|
+
* with the referral, so DS presence needs no further query.
|
|
469
|
+
* 4. Classify every answer (`classifyParentAnswer`) and require at least 2
|
|
470
|
+
* identical conclusive answers through at least 2 different parent
|
|
471
|
+
* nameserver names (`aggregateParentAnswers`). Glue, TTLs, case and order
|
|
472
|
+
* are not compared.
|
|
473
|
+
*
|
|
474
|
+
* DoH never decides a verdict; only the parent's servers do. The DoH
|
|
475
|
+
* lookups never use the system resolver: on a dcrouter host that may be
|
|
476
|
+
* dcrouter itself, which would make the probe self-confirming.
|
|
234
477
|
*
|
|
235
478
|
* The three verdicts are kept distinct on purpose. A timeout is not evidence
|
|
236
479
|
* that a zone is not ours; collapsing 'undeterminable' into 'not-delegated'
|
|
237
480
|
* would let a transient resolver fault revoke authority.
|
|
481
|
+
*
|
|
482
|
+
* Known limitation: the parent servers of a zone below a zone we already
|
|
483
|
+
* serve are our own, which answer for the parent authoritatively, so such a
|
|
484
|
+
* zone is undeterminable.
|
|
238
485
|
*/
|
|
239
486
|
public probeDelegation(zoneArg: string): Promise<IDnsDelegationProbe> {
|
|
240
487
|
if (this.stopped) return Promise.reject(new Error('DNS authority manager is stopped'));
|
|
@@ -249,11 +496,11 @@ export class DnsAuthorityManager {
|
|
|
249
496
|
return run;
|
|
250
497
|
}
|
|
251
498
|
|
|
252
|
-
private createProbeClient():
|
|
499
|
+
private createProbeClient(): TDelegationProbeClient {
|
|
253
500
|
return new plugins.smartdns.dnsClientMod.Smartdns({
|
|
254
501
|
strategy: 'doh',
|
|
255
502
|
allowDohFallback: false,
|
|
256
|
-
timeoutMs:
|
|
503
|
+
timeoutMs: PROBE_DOH_TIMEOUT_MS,
|
|
257
504
|
});
|
|
258
505
|
}
|
|
259
506
|
|
|
@@ -285,69 +532,172 @@ export class DnsAuthorityManager {
|
|
|
285
532
|
const smartdnsClient = this.createProbeClient();
|
|
286
533
|
this.ownedProbeClients.add(smartdnsClient);
|
|
287
534
|
try {
|
|
535
|
+
const deadline = Date.now() + PROBE_DEADLINE_MS;
|
|
536
|
+
const undeterminable = (detailArg: string, parentZoneArg?: string): IDnsDelegationProbe => ({
|
|
537
|
+
zone,
|
|
538
|
+
verdict: 'undeterminable',
|
|
539
|
+
observedNameservers: [],
|
|
540
|
+
expectedNameservers,
|
|
541
|
+
detail: detailArg,
|
|
542
|
+
...(parentZoneArg ? { parentZone: parentZoneArg } : {}),
|
|
543
|
+
});
|
|
288
544
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
result = await smartdnsClient.queryRecords(zone, 'NS');
|
|
292
|
-
} catch (error: unknown) {
|
|
293
|
-
return {
|
|
294
|
-
zone,
|
|
295
|
-
verdict: 'undeterminable',
|
|
296
|
-
observedNameservers: [],
|
|
297
|
-
expectedNameservers,
|
|
298
|
-
detail: `delegation lookup threw: ${(error as Error).message}`,
|
|
299
|
-
};
|
|
300
|
-
}
|
|
545
|
+
const parent = await this.findParentZone(smartdnsClient, zone, deadline);
|
|
546
|
+
if (parent.status === 'failed') return undeterminable(parent.detail);
|
|
301
547
|
|
|
302
|
-
if (
|
|
303
|
-
return
|
|
304
|
-
zone,
|
|
305
|
-
verdict: 'undeterminable',
|
|
306
|
-
observedNameservers: [],
|
|
307
|
-
expectedNameservers,
|
|
308
|
-
detail: `delegation lookup failed (${result.error.kind}/${result.error.code}): ${result.error.message}`,
|
|
309
|
-
};
|
|
548
|
+
if (Date.now() >= deadline) {
|
|
549
|
+
return undeterminable('the probe deadline passed before the parent servers were resolved', parent.parentZone);
|
|
310
550
|
}
|
|
551
|
+
const selection = await this.selectParentServers(smartdnsClient, zone, parent.parentZone, parent.nameservers);
|
|
552
|
+
if (selection.status === 'failed') return undeterminable(selection.detail, parent.parentZone);
|
|
311
553
|
|
|
312
|
-
if (
|
|
313
|
-
return
|
|
314
|
-
zone,
|
|
315
|
-
verdict: 'not-delegated',
|
|
316
|
-
observedNameservers: [],
|
|
317
|
-
expectedNameservers,
|
|
318
|
-
detail: result.missingReason === 'nxdomain'
|
|
319
|
-
? `${zone} does not exist in public DNS`
|
|
320
|
-
: `${zone} has no NS records in public DNS`,
|
|
321
|
-
};
|
|
554
|
+
if (Date.now() >= deadline) {
|
|
555
|
+
return undeterminable('the probe deadline passed before the parent servers were asked', parent.parentZone);
|
|
322
556
|
}
|
|
557
|
+
const answers = await Promise.all(selection.servers.map((serverArg) =>
|
|
558
|
+
this.askParentServer(smartdnsClient, zone, parent.parentZone, serverArg)));
|
|
559
|
+
return aggregateParentAnswers(zone, parent.parentZone, expectedNameservers, answers);
|
|
560
|
+
} finally {
|
|
561
|
+
// Keep ownership if confirmed termination fails; stop() can retry it.
|
|
562
|
+
await smartdnsClient.terminate();
|
|
563
|
+
this.ownedProbeClients.delete(smartdnsClient);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
323
566
|
|
|
324
|
-
|
|
567
|
+
/** One DoH lookup; a thrown lookup is a failed one. */
|
|
568
|
+
private async lookUpOverDoh(
|
|
569
|
+
clientArg: TDelegationProbeClient,
|
|
570
|
+
nameArg: string,
|
|
571
|
+
typeArg: 'NS' | 'A' | 'AAAA',
|
|
572
|
+
): Promise<TProbeDohResult | { status: 'thrown'; message: string }> {
|
|
573
|
+
try {
|
|
574
|
+
return await clientArg.queryRecords(nameArg, typeArg);
|
|
575
|
+
} catch (error: unknown) {
|
|
576
|
+
return { status: 'thrown', message: describeError(error) };
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Step 1: the nearest proper suffix of the zone with an NS RRset, over DoH. */
|
|
581
|
+
private async findParentZone(
|
|
582
|
+
clientArg: TDelegationProbeClient,
|
|
583
|
+
zoneArg: string,
|
|
584
|
+
deadlineArg: number,
|
|
585
|
+
): Promise<TParentZoneLookup> {
|
|
586
|
+
const labels = zoneArg.split('.');
|
|
587
|
+
const candidates = labels.slice(1).map((_labelArg, indexArg) => labels.slice(indexArg + 1).join('.'));
|
|
588
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
589
|
+
if (index >= PARENT_WALK_LIMIT) {
|
|
590
|
+
return { status: 'failed', detail: `no parent zone of ${zoneArg} found within ${PARENT_WALK_LIMIT} levels` };
|
|
591
|
+
}
|
|
592
|
+
if (Date.now() >= deadlineArg) {
|
|
593
|
+
return { status: 'failed', detail: `the probe deadline passed while looking for the parent zone of ${zoneArg}` };
|
|
594
|
+
}
|
|
595
|
+
const result = await this.lookUpOverDoh(clientArg, candidate, 'NS');
|
|
596
|
+
if (result.status === 'thrown') {
|
|
597
|
+
return { status: 'failed', detail: `parent zone lookup for ${candidate} threw: ${result.message}` };
|
|
598
|
+
}
|
|
599
|
+
if (result.status === 'error') {
|
|
600
|
+
return { status: 'failed', detail: `parent zone lookup for ${candidate} failed (${describeDohError(result)})` };
|
|
601
|
+
}
|
|
602
|
+
if (result.status === 'missing') continue;
|
|
603
|
+
const nameservers = [...new Set(
|
|
325
604
|
result.records
|
|
326
|
-
.
|
|
327
|
-
.
|
|
605
|
+
.filter((recordArg) => recordArg.type === 'NS' && normalizeZone(recordArg.name) === candidate)
|
|
606
|
+
.map((recordArg) => normalizeNameserver(String(recordArg.value ?? '')))
|
|
607
|
+
.filter(Boolean),
|
|
328
608
|
)];
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
609
|
+
if (nameservers.length === 0) continue;
|
|
610
|
+
return { status: 'found', parentZone: candidate, nameservers };
|
|
611
|
+
}
|
|
612
|
+
return { status: 'failed', detail: `no parent zone of ${zoneArg} found` };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Step 2: up to 4 distinct parent server addresses over DoH, one per parent
|
|
617
|
+
* nameserver name first. An address belongs to the first name (in sorted
|
|
618
|
+
* order) that resolves to it, so one server never counts for two names.
|
|
619
|
+
*/
|
|
620
|
+
private async selectParentServers(
|
|
621
|
+
clientArg: TDelegationProbeClient,
|
|
622
|
+
zoneArg: string,
|
|
623
|
+
parentZoneArg: string,
|
|
624
|
+
nameserversArg: string[],
|
|
625
|
+
): Promise<TParentServerSelection> {
|
|
626
|
+
const names = [...new Set(nameserversArg)]
|
|
627
|
+
.filter((nameArg) => nameArg !== zoneArg && !nameArg.endsWith(`.${zoneArg}`))
|
|
628
|
+
.sort()
|
|
629
|
+
.slice(0, PARENT_NAME_LIMIT);
|
|
630
|
+
const addressesOf = (
|
|
631
|
+
resultArg: TProbeDohResult | { status: 'thrown'; message: string },
|
|
632
|
+
familyArg: 4 | 6,
|
|
633
|
+
): string[] => resultArg.status === 'success'
|
|
634
|
+
? [...new Set(
|
|
635
|
+
resultArg.records
|
|
636
|
+
.map((recordArg) => String(recordArg.value ?? '').trim().toLowerCase())
|
|
637
|
+
.filter((addressArg) => plugins.net.isIP(addressArg) === familyArg),
|
|
638
|
+
)].sort()
|
|
639
|
+
: [];
|
|
640
|
+
const resolved = await Promise.all(names.map(async (nameserverArg) => {
|
|
641
|
+
const [ipv4, ipv6] = await Promise.all([
|
|
642
|
+
this.lookUpOverDoh(clientArg, nameserverArg, 'A'),
|
|
643
|
+
this.lookUpOverDoh(clientArg, nameserverArg, 'AAAA'),
|
|
644
|
+
]);
|
|
645
|
+
return { nameserver: nameserverArg, addresses: [...addressesOf(ipv4, 4), ...addressesOf(ipv6, 6)] };
|
|
646
|
+
}));
|
|
647
|
+
|
|
648
|
+
const claimed = new Set<string>();
|
|
649
|
+
const perName = resolved
|
|
650
|
+
.map((entryArg) => ({
|
|
651
|
+
nameserver: entryArg.nameserver,
|
|
652
|
+
addresses: entryArg.addresses.filter((addressArg) => {
|
|
653
|
+
if (claimed.has(addressArg)) return false;
|
|
654
|
+
claimed.add(addressArg);
|
|
655
|
+
return true;
|
|
656
|
+
}),
|
|
657
|
+
}))
|
|
658
|
+
.filter((entryArg) => entryArg.addresses.length > 0);
|
|
659
|
+
if (perName.length < PARENT_AGREEMENT_MINIMUM) {
|
|
339
660
|
return {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
expectedNameservers,
|
|
344
|
-
detail: `${zone} is delegated to ${matches.join(', ')}`,
|
|
661
|
+
status: 'failed',
|
|
662
|
+
detail: `only ${perName.length} of the ${names.length} usable nameserver name(s) of ${parentZoneArg} `
|
|
663
|
+
+ `(${names.join(', ') || 'none'}) have a resolvable address; at least ${PARENT_AGREEMENT_MINIMUM} are needed`,
|
|
345
664
|
};
|
|
346
|
-
} finally {
|
|
347
|
-
// Keep ownership if confirmed termination fails; stop() can retry it.
|
|
348
|
-
await smartdnsClient.terminate();
|
|
349
|
-
this.ownedProbeClients.delete(smartdnsClient);
|
|
350
665
|
}
|
|
666
|
+
|
|
667
|
+
const servers: IParentServer[] = [];
|
|
668
|
+
for (let round = 0; servers.length < PARENT_SERVER_LIMIT; round++) {
|
|
669
|
+
const picks = perName
|
|
670
|
+
.filter((entryArg) => round < entryArg.addresses.length)
|
|
671
|
+
.map((entryArg) => ({ address: entryArg.addresses[round], nameserver: entryArg.nameserver }));
|
|
672
|
+
if (picks.length === 0) break;
|
|
673
|
+
servers.push(...picks.slice(0, PARENT_SERVER_LIMIT - servers.length));
|
|
674
|
+
}
|
|
675
|
+
return { status: 'selected', servers };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Step 3: the non-recursive NS query for the zone to one parent server. */
|
|
679
|
+
private async askParentServer(
|
|
680
|
+
clientArg: TDelegationProbeClient,
|
|
681
|
+
zoneArg: string,
|
|
682
|
+
parentZoneArg: string,
|
|
683
|
+
serverArg: IParentServer,
|
|
684
|
+
): Promise<IDnsDelegationParentAnswer> {
|
|
685
|
+
const identity = { server: formatParentServer(serverArg.address), nameserver: serverArg.nameserver };
|
|
686
|
+
let result: TProbeServerResult;
|
|
687
|
+
try {
|
|
688
|
+
result = await clientArg.queryServer({
|
|
689
|
+
server: serverArg.address,
|
|
690
|
+
name: zoneArg,
|
|
691
|
+
type: 'NS',
|
|
692
|
+
recursionDesired: false,
|
|
693
|
+
dnssecOk: true,
|
|
694
|
+
transport: 'udp',
|
|
695
|
+
timeoutMs: PARENT_QUERY_TIMEOUT_MS,
|
|
696
|
+
});
|
|
697
|
+
} catch (error: unknown) {
|
|
698
|
+
return { ...identity, outcome: 'inconclusive', detail: `the query threw: ${describeError(error)}` };
|
|
699
|
+
}
|
|
700
|
+
return { ...identity, ...classifyParentAnswer(zoneArg, parentZoneArg, result) };
|
|
351
701
|
}
|
|
352
702
|
|
|
353
703
|
// ==========================================================================
|
|
@@ -357,6 +707,8 @@ export class DnsAuthorityManager {
|
|
|
357
707
|
/**
|
|
358
708
|
* Claim authority over a zone, but only against a positive delegation proof.
|
|
359
709
|
* An undeterminable probe refuses the mutation — never assume either way.
|
|
710
|
+
* A delegation whose parent zone publishes a DS for the zone is refused as
|
|
711
|
+
* well (`dsPresent`); the DS has to be removed at the registrar first.
|
|
360
712
|
*/
|
|
361
713
|
public async verifyZone(zoneArg: string, verifiedBy: string): Promise<IDnsAuthorityMutationResult> {
|
|
362
714
|
const zone = normalizeZone(zoneArg);
|
|
@@ -375,6 +727,14 @@ export class DnsAuthorityManager {
|
|
|
375
727
|
: `Cannot verify ${zone}: ${probe.detail}`,
|
|
376
728
|
};
|
|
377
729
|
}
|
|
730
|
+
if (probe.dsPresent) {
|
|
731
|
+
return {
|
|
732
|
+
success: false,
|
|
733
|
+
probe,
|
|
734
|
+
message: `Cannot verify ${zone}: ${probe.parentZone ?? 'the parent zone'} publishes a DS for ${zone}, `
|
|
735
|
+
+ 'and dcrouter refuses zones with a DS. Remove the DS at the registrar, then verify again.',
|
|
736
|
+
};
|
|
737
|
+
}
|
|
378
738
|
|
|
379
739
|
const entry: IDnsAuthorityZone = {
|
|
380
740
|
zone,
|
|
@@ -527,7 +887,10 @@ export class DnsAuthorityManager {
|
|
|
527
887
|
observedNameservers: probe.observedNameservers,
|
|
528
888
|
expectedNameservers: probe.expectedNameservers,
|
|
529
889
|
detail: `${zone} is delegated to ${probe.observedNameservers.join(', ')} but is not in the authority set, `
|
|
530
|
-
+ 'so dcrouter refuses to serve it authoritatively and will not request certificates for it'
|
|
890
|
+
+ 'so dcrouter refuses to serve it authoritatively and will not request certificates for it'
|
|
891
|
+
+ (probe.dsPresent
|
|
892
|
+
? `; ${probe.parentZone ?? 'the parent zone'} publishes a DS for ${zone}, which blocks the claim until it is removed`
|
|
893
|
+
: ''),
|
|
531
894
|
});
|
|
532
895
|
}
|
|
533
896
|
|