@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.
@@ -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 public NS records must
58
- * name our `dnsNsDomains`. An ops-API caller cannot fake that without actually
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<plugins.smartdns.dnsClientMod.Smartdns>();
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 public DNS whether `zone` is delegated to our nameservers.
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
- * Deliberately uses `strategy: 'doh'` — a single DNS-over-HTTPS attempt
229
- * against a public resolver, with no system-resolver fallback. smartdns'
230
- * `getNameServers()` uses `dns.resolveNs`, i.e. the *system* resolver, which on
231
- * a dcrouter host may be dcrouter itself: it would happily answer with the very
232
- * NS records we generated, making the probe self-confirming. An independent
233
- * vantage point is the whole point of the proof.
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(): plugins.smartdns.dnsClientMod.Smartdns {
499
+ private createProbeClient(): TDelegationProbeClient {
253
500
  return new plugins.smartdns.dnsClientMod.Smartdns({
254
501
  strategy: 'doh',
255
502
  allowDohFallback: false,
256
- timeoutMs: 10_000,
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
- let result: Awaited<ReturnType<typeof smartdnsClient.queryRecords>>;
290
- try {
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 (result.status === 'error') {
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 (result.status === 'missing') {
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
- const observedNameservers: string[] = [...new Set<string>(
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
- .map((record): string => normalizeNameserver(String(record.value ?? '')))
327
- .filter((nameserver): nameserver is string => Boolean(nameserver)),
605
+ .filter((recordArg) => recordArg.type === 'NS' && normalizeZone(recordArg.name) === candidate)
606
+ .map((recordArg) => normalizeNameserver(String(recordArg.value ?? '')))
607
+ .filter(Boolean),
328
608
  )];
329
- const matches = observedNameservers.filter((observed) => expectedNameservers.includes(observed));
330
- if (matches.length === 0) {
331
- return {
332
- zone,
333
- verdict: 'not-delegated',
334
- observedNameservers,
335
- expectedNameservers,
336
- detail: `${zone} is delegated to ${observedNameservers.join(', ') || 'nothing'}, which does not include any of our nameservers (${expectedNameservers.join(', ')})`,
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
- zone,
341
- verdict: 'delegated',
342
- observedNameservers,
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
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '34.0.0',
6
+ version: '34.1.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }