@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.
@@ -13,10 +13,15 @@
13
13
  * The fix is not to make the declared list editable. A list an operator can
14
14
  * simply append to is self-assertion, which is precisely the failure mode that
15
15
  * let routes claim domains nobody verified we own. Instead a zone earns
16
- * authority by *proof*: its public delegation must name our nameservers. That
17
- * proof is unforgeable by an ops-API caller — you cannot point `example.com`'s
18
- * NS records at our nameservers unless you actually control the domain — and it
19
- * is the missing comparison between the two representations.
16
+ * authority by *proof*: its delegation in the parent zone must name our
17
+ * nameservers. That proof is unforgeable by an ops-API caller — you cannot point
18
+ * `example.com`'s delegation at our nameservers unless you actually control the
19
+ * domain — and it is the missing comparison between the two representations.
20
+ *
21
+ * The proof is read from the parent zone's own servers, asked without
22
+ * recursion, never from a recursive resolver: a recursive lookup of a zone
23
+ * delegated to us but not claimed yet reaches our servers, which REFUSE it, so
24
+ * it could never prove the delegation it is supposed to prove.
20
25
  *
21
26
  * `dnsScopes` is gone entirely. There is exactly one representation of DNS
22
27
  * authority now, it lives in the database, and it is the one that carries its
@@ -37,7 +42,10 @@ export interface IDnsAuthorityZone {
37
42
  origin: TDnsAuthorityZoneOrigin;
38
43
  /** When the delegation proof was observed. */
39
44
  verifiedAt?: number;
40
- /** Nameservers observed at verification time — the evidence for the proof. */
45
+ /**
46
+ * The NS set the parent zone's servers delegated the zone to at verification
47
+ * time — the evidence for the proof.
48
+ */
41
49
  observedNameservers?: string[];
42
50
  /** Operator who performed the verification. */
43
51
  verifiedBy?: string;
@@ -62,20 +70,69 @@ export interface IDnsAuthoritySettings {
62
70
  updatedBy: string;
63
71
  }
64
72
  /**
65
- * Outcome of a delegation probe.
73
+ * Outcome of a delegation probe, decided by the parent zone's servers alone.
74
+ *
75
+ * - 'delegated': at least two of the parent's servers, reached through at least
76
+ * two different parent nameserver names, gave the identical referral for the
77
+ * zone, and its NS set names at least one of our nameservers. A referral that
78
+ * carries a DS sets `dsPresent`, and such a zone cannot be claimed.
79
+ * - 'not-delegated': the same agreement on a referral that names none of our
80
+ * nameservers, or on an authoritative "no such delegation" (NXDOMAIN or NODATA
81
+ * with the parent's SOA). Also the verdict for an unusable zone name.
82
+ * - 'undeterminable': anything less — a failed lookup, fewer than two
83
+ * conclusive answers, or any disagreement between them.
66
84
  *
67
85
  * 'undeterminable' is deliberately distinct from 'not-delegated': a resolver
68
86
  * timeout is not evidence that a zone is not ours. Conflating them would let a
69
87
  * transient network fault revoke authority, turning a blip into an outage.
70
88
  */
71
89
  export type TDnsDelegationVerdict = 'delegated' | 'not-delegated' | 'undeterminable';
90
+ /**
91
+ * How the probe classified one parent server's answer to the non-recursive
92
+ * NS query for the zone.
93
+ *
94
+ * - 'referral': RCODE 0, AA=0, an empty answer section, and an authority
95
+ * section whose NS records are all owned by the zone, with an OPT record that
96
+ * echoes DO=1 (otherwise a missing DS would prove nothing).
97
+ * - 'negative': AA=1 NXDOMAIN or NODATA with an empty answer section and the
98
+ * parent zone's SOA in the authority section.
99
+ * - 'inconclusive': everything else, including timeouts, REFUSED, any other
100
+ * AA=1 answer (a server that hosts the parent and the child zone answers for
101
+ * the child instead of referring) and referrals to any other name.
102
+ */
103
+ export type TDnsDelegationParentOutcome = 'referral' | 'negative' | 'inconclusive';
104
+ /** One parent server's answer to the non-recursive NS query for the zone. */
105
+ export interface IDnsDelegationParentAnswer {
106
+ /** The parent server the probe asked, as `ip:port` (`[ip]:port` for IPv6). */
107
+ server: string;
108
+ /** The parent nameserver name this address belongs to. */
109
+ nameserver: string;
110
+ outcome: TDnsDelegationParentOutcome;
111
+ /** The referral's NS set, lowercased, trailing-dot stripped and sorted (referral only). */
112
+ nameservers?: string[];
113
+ /** Whether the referral carried a DS RRset for the zone (referral only). */
114
+ dsPresent?: boolean;
115
+ detail: string;
116
+ }
72
117
  export interface IDnsDelegationProbe {
73
118
  zone: string;
74
119
  verdict: TDnsDelegationVerdict;
75
- /** NS records observed for the zone, lowercased and trailing-dot stripped. */
120
+ /**
121
+ * The NS set the parent zone's servers delegate the zone to, lowercased and
122
+ * trailing-dot stripped; empty when there is no agreed referral.
123
+ */
76
124
  observedNameservers: string[];
77
125
  expectedNameservers: string[];
78
126
  detail: string;
127
+ /** The parent zone whose servers were asked; absent when it was not found. */
128
+ parentZone?: string;
129
+ /**
130
+ * Whether the agreed referral carried a DS for the zone. Set on a verdict
131
+ * that rests on a referral; a `delegated` zone with a DS cannot be claimed.
132
+ */
133
+ dsPresent?: boolean;
134
+ /** Every parent server's classified answer, when parent servers were asked. */
135
+ parentAnswers?: IDnsDelegationParentAnswer[];
79
136
  }
80
137
  /** A disagreement between claimed authority and what is actually true. */
81
138
  export type TDnsAuthorityDriftKind =
@@ -13,14 +13,19 @@
13
13
  * The fix is not to make the declared list editable. A list an operator can
14
14
  * simply append to is self-assertion, which is precisely the failure mode that
15
15
  * let routes claim domains nobody verified we own. Instead a zone earns
16
- * authority by *proof*: its public delegation must name our nameservers. That
17
- * proof is unforgeable by an ops-API caller — you cannot point `example.com`'s
18
- * NS records at our nameservers unless you actually control the domain — and it
19
- * is the missing comparison between the two representations.
16
+ * authority by *proof*: its delegation in the parent zone must name our
17
+ * nameservers. That proof is unforgeable by an ops-API caller — you cannot point
18
+ * `example.com`'s delegation at our nameservers unless you actually control the
19
+ * domain — and it is the missing comparison between the two representations.
20
+ *
21
+ * The proof is read from the parent zone's own servers, asked without
22
+ * recursion, never from a recursive resolver: a recursive lookup of a zone
23
+ * delegated to us but not claimed yet reaches our servers, which REFUSE it, so
24
+ * it could never prove the delegation it is supposed to prove.
20
25
  *
21
26
  * `dnsScopes` is gone entirely. There is exactly one representation of DNS
22
27
  * authority now, it lives in the database, and it is the one that carries its
23
28
  * own evidence.
24
29
  */
25
30
  export {};
26
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZG5zLWF1dGhvcml0eS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3RzX2ludGVyZmFjZXMvZGF0YS9kbnMtYXV0aG9yaXR5LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7OztHQXVCRyJ9
31
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZG5zLWF1dGhvcml0eS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3RzX2ludGVyZmFjZXMvZGF0YS9kbnMtYXV0aG9yaXR5LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7O0dBNEJHIn0=
@@ -12,8 +12,9 @@ export interface IReq_GetDnsAuthority extends plugins.typedrequestInterfaces.imp
12
12
  };
13
13
  }
14
14
  /**
15
- * Probe a zone's public delegation without changing anything. Lets an operator
16
- * see the evidence before claiming authority.
15
+ * Probe a zone's delegation in its parent zone without changing anything. Lets
16
+ * an operator see the evidence (the parent zone and each parent server's
17
+ * answer) before claiming authority.
17
18
  */
18
19
  export interface IReq_ProbeDnsAuthorityZone extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_ProbeDnsAuthorityZone> {
19
20
  method: 'probeDnsAuthorityZone';
@@ -27,9 +28,10 @@ export interface IReq_ProbeDnsAuthorityZone extends plugins.typedrequestInterfac
27
28
  };
28
29
  }
29
30
  /**
30
- * Claim authority over a zone. Succeeds only when the zone's public delegation
31
- * names our nameservers; the claim is then persisted and reconciled in-process,
32
- * with no restart.
31
+ * Claim authority over a zone. Succeeds only on a `delegated` probe without a
32
+ * DS: the parent zone's servers agree that the zone is delegated to our
33
+ * nameservers and publish no DS for it. The claim is then persisted and
34
+ * reconciled in-process, with no restart.
33
35
  */
34
36
  export interface IReq_VerifyDnsAuthorityZone extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_VerifyDnsAuthorityZone> {
35
37
  method: 'verifyDnsAuthorityZone';
@@ -3,7 +3,7 @@
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
  };
9
9
  //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vdHNfd2ViLzAwX2NvbW1pdGluZm9fZGF0YS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7R0FFRztBQUNILE1BQU0sQ0FBQyxNQUFNLFVBQVUsR0FBRztJQUN4QixJQUFJLEVBQUUsc0JBQXNCO0lBQzVCLE9BQU8sRUFBRSxRQUFRO0lBQ2pCLFdBQVcsRUFBRSwwRUFBMEU7Q0FDeEYsQ0FBQSJ9
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@serve.zone/dcrouter",
3
3
  "private": false,
4
- "version": "34.0.0",
4
+ "version": "34.1.0",
5
5
  "description": "A multifaceted routing service handling mail and SMS delivery functions.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -20,8 +20,10 @@
20
20
  "@git.zone/tsrepo": "^1.1.1",
21
21
  "@push.rocks/smartdb-v2": "npm:@push.rocks/smartdb@2.14.6",
22
22
  "@push.rocks/smartdb-v5.2": "npm:@push.rocks/smartdb@5.2.2",
23
+ "@types/dns-packet": "^5.6.5",
23
24
  "@types/node": "26.6.5",
24
25
  "@types/web-push": "3.6.4",
26
+ "dns-packet": "^5.6.1",
25
27
  "tsx": "^4.23.15"
26
28
  },
27
29
  "dependencies": {
@@ -41,7 +43,7 @@
41
43
  "@push.rocks/smartbucket": "^4.13.0",
42
44
  "@push.rocks/smartchallenge": "^1.1.0",
43
45
  "@push.rocks/smartdb": "5.7.1",
44
- "@push.rocks/smartdns": "^8.2.1",
46
+ "@push.rocks/smartdns": "^11.1.0",
45
47
  "@push.rocks/smartfs": "^1.7.0",
46
48
  "@push.rocks/smartguard": "^3.1.2",
47
49
  "@push.rocks/smartjwt": "^2.5.1",
package/readme.md CHANGED
@@ -530,13 +530,55 @@ records and testing DNS keep their own durable records and are not journaled her
530
530
  Which zones the embedded DNS server may answer for authoritatively is database
531
531
  state, not deployment configuration. There is no `dnsScopes` option.
532
532
 
533
- A zone enters the authority set exactly one way: its **public delegation must
534
- name one of `dnsNsDomains`**, observed through a single DNS-over-HTTPS lookup
535
- against a public resolver. The system resolver is never used for this — on a
536
- dcrouter host it may be dcrouter itself, which would answer with the very NS
537
- records dcrouter generated and make the proof self-confirming. An ops-API caller
538
- cannot repoint somebody else's delegation, so writing the record is not the same
539
- as manufacturing the proof.
533
+ A zone enters the authority set exactly one way: its **delegation in the parent
534
+ zone must name one of `dnsNsDomains`**, read from the parent zone's own servers.
535
+ An ops-API caller cannot repoint somebody else's delegation, so writing the
536
+ record is not the same as manufacturing the proof.
537
+
538
+ The probe never looks the zone up through a recursive resolver: for a zone that
539
+ is delegated to dcrouter but not claimed yet, the resolver would ask dcrouter,
540
+ which REFUSES the unclaimed zone, and the lookup would fail with SERVFAIL. It
541
+ works like this instead:
542
+
543
+ 1. It finds the parent zone over DNS-over-HTTPS: the nearest proper suffix of the
544
+ zone that has NS records (at most 8 levels up). The zone itself and names
545
+ below it are never asked over DoH, and a failed lookup at any level makes the
546
+ probe `undeterminable` rather than skipping to a grandparent.
547
+ 2. It looks up A and AAAA of up to 8 of the parent's nameserver names over DoH
548
+ and picks up to 4 distinct addresses: one per name first, IPv4 before IPv6.
549
+ Fewer than 2 names with an address is `undeterminable`.
550
+ 3. It asks each picked server for the zone's NS records without recursion
551
+ (RD=0) and with DO=1, over UDP and TCP when the answer is truncated.
552
+ 4. Only two answers are conclusive. A referral: no AA bit, an empty answer
553
+ section, authority NS records all owned by the zone, and an OPT record that
554
+ echoes DO=1. A negative: an authoritative NXDOMAIN or NODATA with an empty
555
+ answer section and the parent zone's SOA. Anything else is inconclusive,
556
+ including an authoritative answer from a server that hosts both the parent
557
+ and the zone.
558
+ 5. At least 2 conclusive answers, through at least 2 different parent
559
+ nameserver names, must be identical (the NS set and whether a DS came with
560
+ it; glue, TTLs, case and order are ignored). Any disagreement is
561
+ `undeterminable`.
562
+
563
+ The result is `delegated` when the agreed referral names one of `dnsNsDomains`,
564
+ and `not-delegated` when it names none of them (a foreign delegation) or the
565
+ parent has no delegation for the zone. `observedNameservers` is the NS set the
566
+ parent delegates to, and the probe also returns `parentZone`, `dsPresent` and
567
+ every parent server's classified answer (`parentAnswers`). DoH never decides a
568
+ verdict, and the system resolver is never used: on a dcrouter host it may be
569
+ dcrouter itself, which would make the proof self-confirming.
570
+
571
+ A zone whose parent publishes a **DS** for it is reported as `delegated` with
572
+ `dsPresent: true`, and `verifyDnsAuthorityZone` refuses it: remove the DS at the
573
+ registrar, then verify. The drift audit says so too. A zone below a zone dcrouter
574
+ already serves is `undeterminable`: its parent servers are dcrouter's own, which
575
+ answer for the parent authoritatively instead of referring.
576
+
577
+ DoH lookups time out after 5 s and each parent server attempt after 2 s (a
578
+ truncated UDP answer gets one more TCP attempt). No probe step starts after
579
+ 30 s, so the probe's lookups and queries end within 35 s (plus starting and
580
+ stopping the probe's DNS client); a claim holds the DNS mutation lock about
581
+ that long at most while it probes.
540
582
 
541
583
  | Operation | Scope | Effect |
542
584
  | --- | --- | --- |
@@ -549,7 +591,13 @@ as manufacturing the proof.
549
591
  Writes accept an admin identity or an API token carrying `dns-authority:write`;
550
592
  a non-admin identity is refused. Probes return three verdicts, never two: `delegated`, `not-delegated`, and
551
593
  `undeterminable`. A timeout is not evidence that a zone is not ours, so a
552
- mutation refuses on `undeterminable` rather than assuming either way.
594
+ mutation refuses on `undeterminable` rather than assuming either way, and a
595
+ claim also refuses a `delegated` zone with a DS.
596
+
597
+ The embedded DNS server synthesizes every SOA itself, with TTL 3600, refresh
598
+ 3600, retry 600, expire 604800, minimum 86400, rname `hostmaster.<zone>` and
599
+ mname the first `dnsNsDomains` entry (smartdns falls back to `ns1.<dnssecZone>`, the DNSSEC signing zone, when
600
+ `dnsNsDomains` is empty); negative answers carry the SOA with TTL 3600.
553
601
 
554
602
  Claiming or revoking a zone takes effect in-process, with no restart: it moves
555
603
  the running DNS server's authoritative zone set, its generated apex NS records,
@@ -785,8 +833,9 @@ const router = new DcRouter({
785
833
  emailOutboundMode: 'remoteIngress',
786
834
  dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
787
835
  // Which zones the embedded DNS server answers for is not configured here.
788
- // A zone earns authority by its public delegation naming dnsNsDomains, and
789
- // that proof lives in the database — see the "DNS Authority" section above.
836
+ // A zone earns authority by its delegation in the parent zone naming
837
+ // dnsNsDomains, and that proof lives in the database — see the "DNS
838
+ // Authority" section above.
790
839
  publicIp: '203.0.113.10',
791
840
  remoteIngressConfig: {
792
841
  enabled: true,
@@ -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
  }
@@ -13,6 +13,24 @@ type TDnsRecordSeed = { name: string; type: string; value: string; ttl?: number;
13
13
  /** The required DNSSEC keying zone when dcrouter currently claims no DNS zone. */
14
14
  export const NO_AUTHORITY_SENTINEL_ZONE = 'no-authority.invalid';
15
15
 
16
+ /**
17
+ * The server-wide SOA values of the embedded DNS server.
18
+ *
19
+ * smartdns 11 lowered its default SOA `minimum` from 86400 to 300, which would
20
+ * cut the negative-answer SOA TTL from min(3600, 86400) = 3600 to 300. The pin
21
+ * keeps the wire values dcrouter has always served: TTL 3600, refresh 3600,
22
+ * retry 600, expire 604800, minimum 86400, rname `hostmaster.<zone>`, and mname
23
+ * the primary nameserver, else smartdns' `ns1.<dnssecZone>`. smartdns refuses
24
+ * `soaDefaults.mname` together with the deprecated `primaryNameserver` with a
25
+ * TypeError, so dcrouter sets the mname here and never passes the latter.
26
+ */
27
+ export const buildDnsServerSoaDefaults = (
28
+ primaryNameserverArg: string | undefined,
29
+ ): Partial<plugins.smartdns.dnsServerMod.IDnsSoaSettings> => ({
30
+ ...(primaryNameserverArg ? { mname: primaryNameserverArg } : {}),
31
+ minimum: 86400,
32
+ });
33
+
16
34
  /**
17
35
  * Sets up and feeds the embedded authoritative smartdns server: validates the
18
36
  * DNS configuration, generates authoritative/email/DKIM records, applies
@@ -139,7 +157,8 @@ export class DnsServerRuntime {
139
157
  dnssecZone: bootAuthorityZones[0] || NO_AUTHORITY_SENTINEL_ZONE,
140
158
  // SmartDNS copies this boot snapshot; later changes use its live setter.
141
159
  authoritativeZones: bootAuthorityZones,
142
- primaryNameserver: primaryNameserver, // Automatically generates correct SOA records
160
+ // smartdns synthesizes every SOA (apex and negative answers) from these.
161
+ soaDefaults: buildDnsServerSoaDefaults(primaryNameserver),
143
162
  // For now, use self-signed cert until we integrate with Let's Encrypt
144
163
  httpsKey: '',
145
164
  httpsCert: ''
@@ -828,7 +847,7 @@ export class DnsServerRuntime {
828
847
  }
829
848
 
830
849
  // Apex NS records are owned by DnsManager (see the doc comment above), and
831
- // SOA is synthesized by smartdns from `primaryNameserver`.
850
+ // SOA is synthesized by smartdns from `soaDefaults` (buildDnsServerSoaDefaults).
832
851
  return records;
833
852
  }
834
853