@serve.zone/dcrouter 17.10.2 → 18.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +360 -360
  3. package/dist_ts/00_commitinfo_data.js +2 -2
  4. package/dist_ts/acme/acme-failure-classification.d.ts +64 -0
  5. package/dist_ts/acme/acme-failure-classification.js +114 -0
  6. package/dist_ts/acme/classes.smartacme-lifecycle.d.ts +42 -0
  7. package/dist_ts/acme/classes.smartacme-lifecycle.js +75 -4
  8. package/dist_ts/acme/index.d.ts +1 -0
  9. package/dist_ts/acme/index.js +2 -1
  10. package/dist_ts/classes.dcrouter.d.ts +33 -9
  11. package/dist_ts/classes.dcrouter.js +145 -11
  12. package/dist_ts/config/classes.route-config-manager.d.ts +56 -0
  13. package/dist_ts/config/classes.route-config-manager.js +161 -1
  14. package/dist_ts/db/documents/classes.dns-authority.doc.d.ts +30 -0
  15. package/dist_ts/db/documents/classes.dns-authority.doc.js +108 -0
  16. package/dist_ts/db/documents/index.d.ts +1 -0
  17. package/dist_ts/db/documents/index.js +2 -1
  18. package/dist_ts/dns/classes.dns-server-runtime.d.ts +109 -1
  19. package/dist_ts/dns/classes.dns-server-runtime.js +212 -34
  20. package/dist_ts/dns/domain-ownership.d.ts +111 -0
  21. package/dist_ts/dns/domain-ownership.js +152 -0
  22. package/dist_ts/dns/index.d.ts +2 -0
  23. package/dist_ts/dns/index.js +3 -1
  24. package/dist_ts/dns/manager.dns-authority.d.ts +143 -0
  25. package/dist_ts/dns/manager.dns-authority.js +481 -0
  26. package/dist_ts/dns/manager.dns.d.ts +121 -12
  27. package/dist_ts/dns/manager.dns.js +298 -28
  28. package/dist_ts/errors/error.codes.d.ts +4 -0
  29. package/dist_ts/errors/error.codes.js +5 -1
  30. package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
  31. package/dist_ts/opsserver/classes.opsserver.js +3 -1
  32. package/dist_ts/opsserver/handlers/acme-config.handler.js +6 -1
  33. package/dist_ts/opsserver/handlers/certificate.handler.d.ts +16 -0
  34. package/dist_ts/opsserver/handlers/certificate.handler.js +96 -10
  35. package/dist_ts/opsserver/handlers/config.handler.js +4 -2
  36. package/dist_ts/opsserver/handlers/dns-authority.handler.d.ts +20 -0
  37. package/dist_ts/opsserver/handlers/dns-authority.handler.js +108 -0
  38. package/dist_ts/opsserver/handlers/dns-provider.handler.js +5 -1
  39. package/dist_ts/opsserver/handlers/domain.handler.js +9 -1
  40. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +2 -2
  41. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  42. package/dist_ts/opsserver/handlers/index.js +2 -1
  43. package/dist_ts_interfaces/data/dns-authority.d.ts +98 -0
  44. package/dist_ts_interfaces/data/dns-authority.js +26 -0
  45. package/dist_ts_interfaces/data/index.d.ts +1 -0
  46. package/dist_ts_interfaces/data/index.js +2 -1
  47. package/dist_ts_interfaces/data/route-management.d.ts +9 -2
  48. package/dist_ts_interfaces/data/route-management.js +3 -1
  49. package/dist_ts_interfaces/requests/certificate.d.ts +23 -0
  50. package/dist_ts_interfaces/requests/certificate.js +1 -1
  51. package/dist_ts_interfaces/requests/dns-authority.d.ts +80 -0
  52. package/dist_ts_interfaces/requests/dns-authority.js +3 -0
  53. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  54. package/dist_ts_interfaces/requests/index.js +2 -1
  55. package/dist_ts_oci_container/index.js +9 -4
  56. package/dist_ts_web/00_commitinfo_data.js +2 -2
  57. package/package.json +1 -1
  58. package/readme.hints.md +412 -0
  59. package/readme.md +48 -6
  60. package/ts/00_commitinfo_data.ts +1 -1
  61. package/ts/acme/acme-failure-classification.ts +201 -0
  62. package/ts/acme/classes.smartacme-lifecycle.ts +104 -3
  63. package/ts/acme/index.ts +1 -0
  64. package/ts/classes.dcrouter.ts +193 -23
  65. package/ts/config/classes.route-config-manager.ts +197 -0
  66. package/ts/db/documents/classes.dns-authority.doc.ts +49 -0
  67. package/ts/db/documents/index.ts +1 -0
  68. package/ts/dns/classes.dns-server-runtime.ts +257 -38
  69. package/ts/dns/domain-ownership.ts +272 -0
  70. package/ts/dns/index.ts +2 -0
  71. package/ts/dns/manager.dns-authority.ts +558 -0
  72. package/ts/dns/manager.dns.ts +373 -27
  73. package/ts/errors/error.codes.ts +4 -0
  74. package/ts/opsserver/classes.opsserver.ts +2 -0
  75. package/ts/opsserver/handlers/acme-config.handler.ts +7 -0
  76. package/ts/opsserver/handlers/certificate.handler.ts +103 -8
  77. package/ts/opsserver/handlers/config.handler.ts +3 -1
  78. package/ts/opsserver/handlers/dns-authority.handler.ts +142 -0
  79. package/ts/opsserver/handlers/dns-provider.handler.ts +6 -0
  80. package/ts/opsserver/handlers/domain.handler.ts +12 -0
  81. package/ts/opsserver/handlers/gatewayclient.handler.ts +1 -1
  82. package/ts/opsserver/handlers/index.ts +1 -0
  83. package/ts/readme.md +1 -1
  84. package/ts_web/00_commitinfo_data.ts +1 -1
package/readme.hints.md CHANGED
@@ -1,5 +1,417 @@
1
1
  # Implementation Hints and Learnings
2
2
 
3
+ ## Derived state must name its invalidation trigger (2026-07-25)
4
+
5
+ Three separate production failures on one day had the same shape: **state was
6
+ mutated in one layer and the mirrored state in another layer was left behind.**
7
+
8
+ 1. `DomainDoc` deleted → the generated apex NS handler stayed registered in the
9
+ embedded smartdns server, still answering with the `aa` flag until dcrouter was
10
+ restarted, while record queries already answered REFUSED.
11
+ 2. A certificate was successfully re-issued and stored → SmartProxy's private
12
+ per-domain `certFailureAt` cooldown still gated the domain, so every route
13
+ reapply skipped it and the proxy kept serving the expired certificate. No
14
+ error, no log (the sweep returns before its summary line when every domain is
15
+ skipped).
16
+ 3. A null sentinel was translated for validation but persisted raw.
17
+
18
+ ### The rule
19
+
20
+ > Every cache, negative cache, or derived registration must name, in code, the
21
+ > mutation that invalidates it — and the invalidation must run inside the same
22
+ > operation that mutates the source of truth.
23
+
24
+ Corollaries, all of which are review-blocking:
25
+
26
+ - A registration is torn down by the same operation that removes what it was
27
+ derived from. Track what you actually registered; do not recompute "would I
28
+ have registered this?" at teardown time. `DnsServer.unregisterHandler(pattern,
29
+ types)` removes **every** handler for that pattern/type pair, so a recomputed
30
+ guess can tear down another owner's records — or miss its own when the inputs
31
+ have drifted. `DnsManager.runtimeRegistrations` is the pattern: a per-domain
32
+ registry of `name|type` keys, each tagged `persisted` or `generated-default`,
33
+ torn down by `tearDownDomainRuntimeRegistrations(domainId)`.
34
+ - A negative cache is invalidated by the event that **resolves its cause**, not
35
+ only by elapsed time. Time-only expiry plus a skip-while-cooling-down rule is a
36
+ deadlock: the sweep that would clear the entry is the sweep the entry blocks.
37
+ - **Issued is not served.** An operation that advances one layer must verify the
38
+ layer that actually serves traffic before reporting success. The certificate
39
+ overview reports `expiryDate` (issued/stored) and `served` (what the Rust engine
40
+ presents) separately and downgrades the combined `status` to `failed` with
41
+ `servedMismatch` when they disagree; `reprovisionCertificateDomain` refuses to
42
+ return success while the engine still serves an invalid certificate.
43
+ - If the layer that owns the derived state exposes no invalidation API, that is
44
+ the defect. A periodic sweep that blindly clears caches, or a shortened TTL, is
45
+ a workaround that hides it — fix the owning layer. Reaching into another
46
+ package's private state (e.g. `smartProxy.bridge`) is an internal bypass and
47
+ must not ship.
48
+
49
+ ### Known open instance (needs an upstream change)
50
+
51
+ `@push.rocks/smartproxy` (27.20.0) keeps `certValidity` and `certFailureAt` as
52
+ **private** in-memory maps and exposes no per-domain invalidation. The only two
53
+ public methods that look relevant, `provisionCertificate(routeName)` and
54
+ `renewCertificate(routeName)`, both go through the Rust ACME bridge, which
55
+ SmartProxy force-disables whenever `certProvisionFunction` is set — so a consumer
56
+ using `certProvisionFunction` has no supported way to clear the cooldown or
57
+ hot-load a certificate for one domain. Smallest correct upstream addition:
58
+ `invalidateCertProvisionCooldown(domain: string): boolean` (plus, ideally, a
59
+ `'skip'` member of `TSmartProxyCertProvisionObject` so a consumer can decline an
60
+ attempt without SmartProxy recording it as a failure and re-arming the cooldown).
61
+ Until that ships, the only operator remedy is toggling the single affected route
62
+ off and on; a dcrouter restart costs 30–60 s of total public outage.
63
+
64
+ ## DNS authority is proven by delegation, not declared (2026-07-25)
65
+
66
+ `dnsScopes` was the last un-migrated bootstrap option in an otherwise DB-driven
67
+ router: read once from the OCI container config at startup, with no mutation
68
+ path, no watcher, no reload. Claiming a newly delegated zone therefore required a
69
+ restart — 30–60 s of total public outage.
70
+
71
+ **The fix is not a `setDnsScopes` mutation.** The domain-ownership predicate
72
+ accepts scope coverage as *proof* precisely because only a deploy can change it.
73
+ An editable declared list is self-assertion, and would have re-opened the exact
74
+ hole that `createDcrouterDomain` had. Instead a zone earns authority by its
75
+ **public delegation naming our `dnsNsDomains`** — unforgeable through the ops API,
76
+ because you cannot repoint a domain's NS records without controlling the domain.
77
+ That is also the comparison that was missing: declared authority versus real
78
+ delegation, made the mechanism rather than an audit bolted on afterwards.
79
+
80
+ - `ts_interfaces/data/dns-authority.ts` — the contract.
81
+ - `ts/db/documents/classes.dns-authority.doc.ts` — singleton doc holding only
82
+ *verified* zones, following the `AcmeConfigDoc` pattern.
83
+ - `ts/dns/manager.dns-authority.ts` — probe, mutations, drift audit.
84
+ - `ts/opsserver/handlers/dns-authority.handler.ts` — `dns-authority:read` /
85
+ `dns-authority:write`, admin identity required on write.
86
+
87
+ **Precedence: there is none, because there is one source.** `dnsScopes` is gone
88
+ — the option, the `DCROUTER_DNS_SCOPES` env override, and the bootstrap floor it
89
+ contributed. The authority set is the delegation-verified zones in the database
90
+ and nothing else.
91
+
92
+ The union it replaced was a floor: always in effect, changeable only by a
93
+ redeploy, and **not revocable through the API**. Those are the same three
94
+ properties that produced the outage this path exists to prevent, so keeping them
95
+ as a safety net kept the disease as the cure.
96
+
97
+ Removing the floor makes the *unreadable database* case load-bearing, which is
98
+ what the original union was really defending against. The defence was right; the
99
+ mechanism was not. Three situations, three answers — and the whole point is that
100
+ they are three, not two:
101
+
102
+ | Situation | Authority set | Behaviour |
103
+ | --- | --- | --- |
104
+ | document missing | known-empty | claim nothing, log at `error`, DNS server still starts |
105
+ | document present, zero zones | known-empty | same, different message — somebody revoked everything, and that is an instruction |
106
+ | document unreadable | **unknown** | `start()` throws; `DnsServer` depends on `DnsManager`, so it does not come up claiming nothing |
107
+
108
+ Rendering an unknown as "claim nothing" is precisely how a transient database
109
+ fault would take every zone off the air. Fail closed on *authority*; never
110
+ silently on *service*. With an empty set the DNS server still binds :53, still
111
+ serves DoH, still runs the private-route overlay, and picks a zone up the moment
112
+ it is verified — REFUSED is a fast, honest "not ours", where an unbound port is
113
+ a timeout.
114
+
115
+ **The probe must not use the system resolver.** `smartdns.getNameServers()` calls
116
+ `dns.resolveNs`, i.e. the system resolver, which on a dcrouter host may be
117
+ dcrouter itself — it would answer with the very NS records we generated, making
118
+ the probe self-confirming. Use
119
+ `new plugins.smartdns.dnsClientMod.Smartdns({ strategy: 'doh', allowDohFallback: false })`
120
+ and `queryRecords(zone, 'NS')`: one DNS-over-HTTPS attempt against a public
121
+ resolver, no system fallback.
122
+
123
+ **Three verdicts, never two.** `delegated` / `not-delegated` / `undeterminable`.
124
+ A timeout is not evidence that a zone is not ours. A mutation refuses on
125
+ `undeterminable` (never assume either way); the startup drift audit *skips* it.
126
+
127
+ **The drift audit is advisory and never mutates.** It reports three directions —
128
+ `delegated-but-unclaimed` (a live zone we could serve but do not),
129
+ `claimed-but-not-delegated` (authority we still assert after delegation moved
130
+ away), and `verified-but-unhosted` (authority with no `DomainDoc` behind it, so
131
+ nothing is served for it at all). It must not revoke: a resolver blip at boot would otherwise take every
132
+ zone off the air, which is the outage this path exists to avoid.
133
+
134
+ ### Deploy sequence — seeding is mandatory, not advisable
135
+
136
+ Startup ordering is already correct: the service chain is `DcRouterDb` →
137
+ `DnsManager` → `SmartProxy` → `DnsServer`, and `DnsAuthorityManager.start()`
138
+ (which loads the stored verified zones) runs inside `DnsManager`'s `withStart`,
139
+ strictly before `dnsServerRuntime.setup()` reads the set or registers any
140
+ handler. So the stored set is applied on the **first** registration pass —
141
+ restart itself introduces no reconciliation gap.
142
+
143
+ **Seed before the first restart on this trio.** With `dnsScopes` gone there is no
144
+ floor left to fall back on: a zone that is not in `DnsAuthorityDoc` when the new
145
+ build starts is REFUSED outright, and that now includes the zone that used to be
146
+ declared in `dnsScopes` and was the only healthy one. Insert the singleton
147
+ `DnsAuthorityDoc` (`settingsId: 'dns-authority-settings'`) with one
148
+ `{ zone, origin: 'verified', verifiedAt, observedNameservers, verifiedBy }`
149
+ entry per delegated zone *before* starting the new build. The first startup then
150
+ has proof already, and the startup drift audit re-probes each entry and reports
151
+ any that no longer hold, so a wrong seed is self-correcting rather than silently
152
+ trusted.
153
+
154
+ The production database is an embedded single-writer `LocalSmartDb` reached over
155
+ a Unix socket in `os.tmpdir()`, so it is not reachable off-host and the seed must
156
+ run on the dcrouter host with dcrouter stopped: stop → seed → start the new
157
+ build. `.nogit/debug/seed-dns-authority.ts` does this; it probes every zone over
158
+ DoH first and writes nothing on an unproven zone, and it is re-runnable (it
159
+ replaces the verified set with what it just proved, so dropping a zone from the
160
+ list drops it from authority).
161
+
162
+ That script lives under `.nogit/` and is therefore **not committed** — it is a
163
+ one-off operational tool for this specific upgrade, not product code, and it is
164
+ deliberately not a smartmigration step (see the next section for why). So it
165
+ cannot be the only record of the step: the changelog spells out the document
166
+ shape (`settingsId: 'dns-authority-settings'`, one
167
+ `{ zone, origin: 'verified', verifiedAt, observedNameservers, verifiedBy }` per
168
+ zone) inline, and the same result is reachable through `verifyDnsAuthorityZone`
169
+ after startup with no restart. The script is a convenience, not a dependency.
170
+
171
+ **If a zone was missed** — claim it after startup, no restart needed. Order:
172
+
173
+ 1. Confirm the manager loaded: `DnsAuthorityManager: N delegation-verified zone(s) loaded`.
174
+ An empty set logs at `error` instead, naming the remediation.
175
+ 2. Read the startup drift audit. Every `delegated-but-unclaimed` line is a zone
176
+ delegated to us that we are refusing to serve — that is the worklist.
177
+ 3. Per zone, `probeDnsAuthorityZone` first (read-only, no mutation), then
178
+ `verifyDnsAuthorityZone` only if the verdict is `delegated`.
179
+ 4. `getDnsAuthority` — expect one `verified` entry per claimed zone, each
180
+ carrying `observedNameservers`.
181
+ 5. `getDnsAuthorityDrift` — expect `[]`.
182
+ 6. Query each claimed zone's apex NS directly against our nameserver and expect an
183
+ authoritative answer naming `dnsNsDomains`. Query `AAAA` and `SOA` too, not
184
+ just `A`: a zone missing from `authoritativeZones` answers `A` and REFUSES the
185
+ rest, which is the defect this work fixes and the only query pattern that
186
+ distinguishes it.
187
+ 7. `getMergedRoutes` — expect no `unverified-domain-ownership` warnings.
188
+ 8. `getCertificateOverview` — expect no entry with `servedMismatch: true`.
189
+
190
+ Healthy is: drift empty, every claimed zone answering `aa` for its apex NS, no
191
+ ownership route warnings, no served/stored certificate mismatch.
192
+
193
+ A `verdict: 'undeterminable'` means the host could not reach a public DoH
194
+ resolver. That is an egress problem to fix, not something to retry blindly — and
195
+ the zone stays unserved until it is resolved, which is the correct fail-closed
196
+ behaviour.
197
+
198
+ ### `authoritativeZones` is reconciled on a running server
199
+
200
+ An earlier revision recorded this as a known limitation: smartdns takes
201
+ `authoritativeZones` in the `DnsServer` constructor, so a zone verified at
202
+ runtime did not join it until the next restart, and it also meant the `DnsServer`
203
+ service only registered when bootstrap `dnsScopes` was non-empty — a
204
+ verified-only configuration never started the DNS server at all. Both are fixed.
205
+
206
+ It was not a cosmetic limitation. **`authoritativeZones` decides the response
207
+ kind, and it is the entire production defect.** smartdns answers a name inside a
208
+ configured zone and REFUSES a name outside every configured zone — *even when a
209
+ handler is registered and answers other qtypes for that exact name*. In
210
+ production that produced, from `212.95.99.130`:
211
+
212
+ ```
213
+ social.io A=NOERROR (aa) AAAA=REFUSED SOA=REFUSED
214
+ hard.global A=NOERROR (aa) AAAA=REFUSED SOA=REFUSED
215
+ central.eu A=NOERROR (aa) AAAA=NOERROR SOA=NOERROR <- the only declared zone
216
+ ```
217
+
218
+ Public recursives turn the REFUSED arm into SERVFAIL — `AAAA social.io` SERVFAILs
219
+ on 1.1.1.1, 8.8.8.8 and 9.9.9.9 — and `curl` does a dual A+AAAA lookup and blocks
220
+ on it, so dual-stack clients see a degraded site. Registering handlers for a
221
+ verified zone without moving this set would have reproduced the identical defect
222
+ for every runtime-verified zone.
223
+
224
+ **How it is reconciled without a restart.** `DnsServerRuntime` owns the array
225
+ instance it passes as `authoritativeZones`. smartdns keeps the options object by
226
+ reference and re-reads the array on every query, so `syncAuthorityZones()`
227
+ mutates it in place and a live server changes its authority set. The alternative
228
+ is re-creating the `DnsServer`, i.e. dropping UDP/TCP 53 — the outage this path
229
+ exists to remove. `assertLiveAuthorityZones()` checks the reference is still
230
+ shared right after construction and logs at `error` if it is not, so a future
231
+ smartdns that copies the array fails loudly instead of silently freezing
232
+ authority at boot; a test asserts the identity against the installed smartdns.
233
+
234
+ `dnssecZone` cannot be reconciled the same way — it is baked into the Rust config
235
+ at start — so it is taken from the boot-time set, which is **sorted** and
236
+ therefore stable across restarts rather than dependent on database insertion
237
+ order. When the set is empty it becomes `no-authority.invalid`: smartdns falls
238
+ back to `[dnssecZone]` when `authoritativeZones` is empty, and `.invalid` is
239
+ reserved by RFC 2606, so an unseeded database provably cannot claim a real name.
240
+
241
+ ### Why removing `dnsScopes` needs no smartmigration step
242
+
243
+ `AGENTS.md` requires DB schema migrations to live exclusively in
244
+ `ts_migrations/index.ts`. This change adds none, deliberately, and the reason is
245
+ worth stating so its absence does not read as an oversight:
246
+
247
+ - **Nothing in the database changes shape.** `DnsAuthorityDoc` is untouched. The
248
+ only type change is narrowing `TDnsAuthorityZoneOrigin` from
249
+ `'bootstrap' | 'verified'` to `'verified'`, and no stored document ever
250
+ carried `'bootstrap'` — `persist()` only ever wrote `verifiedZones`, and
251
+ `verifyZone()` is the only thing that builds an entry. The bootstrap entries
252
+ existed solely as a read-time projection inside `getSettings()`.
253
+ Stronger still: `DnsAuthorityDoc` was introduced in `0d82361`, after the newest
254
+ tag, so no released build has ever created the collection.
255
+ - **Seeding from `dnsScopes` would forge the proof.** A migration step *could*
256
+ read it — `createMigrationRunner` runs in-process at startup and already takes
257
+ deployment-derived seeds — but a `dnsScopes` entry carries no delegation
258
+ evidence. Persisting one as `origin: 'verified'` would manufacture exactly the
259
+ proof this change makes unforgeable, and would reintroduce self-asserted
260
+ authority through the back door.
261
+
262
+ The other candidate — derive authority by probing every dcrouter-hosted
263
+ `DomainDoc` at upgrade time — was rejected because its outcome would depend on
264
+ whether a public DoH resolver happened to be reachable at that moment, so the
265
+ same upgrade would claim different zones on different runs, and a migration does
266
+ not re-run to correct itself. Nondeterministic authority is worse than no
267
+ automation.
268
+
269
+ The upgrade path is instead **seed, then start** (above), backed by three
270
+ independent loud signals if it is skipped: `DnsAuthorityManager.start()` logs at
271
+ `error` on an empty set, `DnsServerRuntime.setup()` logs at `error`, and the
272
+ startup drift audit enumerates precisely the zones to claim. Every one of them is
273
+ remediable through `dns-authority:write` without a restart, which is the property
274
+ that makes a missed seed recoverable rather than an outage.
275
+
276
+ ### Sequenced behind an unreleased smartdns
277
+
278
+ Three items cannot be written against the installed smartdns (7.12.1) and must
279
+ land in the same change that takes the bump. All are recorded at their code
280
+ sites so they cannot be lost.
281
+
282
+ 1. **The private-route overlay must declare itself non-authoritative.** It
283
+ registers `*` for `A` (`registerPrivateRouteHandler`). The hardened smartdns
284
+ authority model *suppresses* a default-`authoritative` handler for any name
285
+ outside every configured zone, so overlay hostnames proven by `provider-zone`
286
+ — owned, but not delegation-verified, therefore not in `authoritativeZones` —
287
+ would silently stop being answered. It needs
288
+ `{ authority: 'non-authoritative', owner: 'private-route-overlay' }`, which
289
+ is a fourth `registerHandler` parameter that does not exist yet.
290
+
291
+ 2. **A supported way to re-zone a running server.** `DnsServerRuntime` currently
292
+ keeps the array it passed as `authoritativeZones` and mutates it in place,
293
+ then verifies through a cast that smartdns still holds the same reference.
294
+ That is a consumer-side shim on a dependency's storage behaviour, and it is
295
+ named as one: `authoritativeZones` is a public option, but "the array is kept
296
+ by reference and re-read per query" is not a documented contract. It is used
297
+ because the alternative — re-creating the `DnsServer` to change its zones —
298
+ drops UDP/TCP 53, which is the outage the whole path exists to remove, and
299
+ because a `setAuthoritativeZones()` cannot be added without releasing
300
+ smartdns first. Guarded two ways in the meantime:
301
+ `assertLiveAuthorityZones()` logs at `error` at startup if the reference is
302
+ no longer shared, and a test asserts the identity against the installed
303
+ smartdns so a copying release fails the suite rather than production. Replace
304
+ both with the supported setter when it ships.
305
+
306
+ 3. **Per-registration teardown.** `unregisterHandler(pattern, types)` is coarse:
307
+ two owners on the identical pattern and type cannot be separated. Nothing
308
+ does that today — `DnsManager` is the single owner of apex NS, and
309
+ `DnsServerRuntime`'s glue records use different patterns — but the registry
310
+ in `DnsManager` exists to work around exactly this. `unregisterHandlerById` /
311
+ `unregisterHandlersByOwner` and the handle returned by `registerHandler` are
312
+ the released fix.
313
+
314
+ ### Generated apex NS has exactly one owner
315
+
316
+ `DnsServerRuntime` used to emit a static apex NS set per `dnsScopes` entry, and
317
+ `DnsManager` skipped those zones to avoid duplicates. Under database-sourced
318
+ authority that is wrong in both directions — a zone verified after startup would
319
+ never get them, and a zone whose authority was revoked would keep them until an
320
+ unrelated restart. `DnsManager.registerAuthoritativeZoneDefaults()` is now the
321
+ only source, for every authoritative zone, reconciled in-process both ways.
322
+
323
+ Consequence worth knowing: generated apex NS hangs off a dcrouter-hosted
324
+ `DomainDoc`, so a verified zone without one serves nothing at all. That is
325
+ reported as `verified-but-unhosted` drift, because it is otherwise invisible —
326
+ such a zone neither REFUSES nor answers.
327
+
328
+ ## Domain ownership gates certificates and authoritative DNS (2026-07-25)
329
+
330
+ `ts/dns/domain-ownership.ts` is the single predicate. Ownership is proven by
331
+ exactly two things, neither forgeable through the ops API:
332
+
333
+ - `provider-zone` — a `DomainDoc` with `source === 'provider'` and a `providerId`.
334
+ It can only get there via `importDomainsFromProvider()`, which requires the zone
335
+ to be listed by a credentialed provider account.
336
+ - `delegation-verified-zone` — the FQDN is covered by a zone in the DNS authority
337
+ set (exact or a subzone). A zone only enters that set by having its public NS
338
+ records observed naming our nameservers, which an ops-API caller cannot
339
+ arrange. This replaced `configured-dns-scope`, which read `options.dnsScopes`:
340
+ deployment configuration was a real trust boundary, but the price was a second,
341
+ un-reconcilable representation of DNS authority.
342
+
343
+ Everything else — including a `source === 'dcrouter'` `DomainDoc` outside the
344
+ authority set — is operator self-assertion and fails closed.
345
+
346
+ **`DomainDoc.authoritative` has been recording intent, not fact.** In production
347
+ all four of `gated.one`, `hard.global`, `shoppinglist.app` and `social.io` carry
348
+ `authoritative: true` with `providerId: no` — a flag nobody ever proved, written
349
+ unconditionally by the old `createDcrouterDomain`. Do not read it as evidence of
350
+ anything. The predicate ignores it and derives authority from proof, and
351
+ `DnsManager.syncAuthoritativeFlags()` now rewrites the stored flag to match the
352
+ current verdict whenever the authority set changes, so the column converges on
353
+ the truth instead of preserving a stale claim.
354
+
355
+ **Known gap — no persisted lifecycle state.** `DomainDoc.source` is only
356
+ `dcrouter | provider` and `updateDomain` can change only `description`, so
357
+ "we intend to manage this later" still cannot be represented distinctly from
358
+ "we serve this authoritatively now"; the invariants above are enforced by
359
+ recomputing ownership at every decision point instead. The recommended target is
360
+ a four-state machine — `pending`/`unverified`, `active-provider`,
361
+ `active-dcrouter`, `suspended`/`revoked` — with a generation-fenced
362
+ `verifyDomain`/`activateDomain` that proves delegation against the configured
363
+ public nameserver identities, `createDomain` entering `pending`, and
364
+ `deactivateDomain` tearing down runtime state. Two properties to preserve when it
365
+ lands: in `active-provider`, `authoritative` stays **false** and loss of provider
366
+ proof must **suspend** writes rather than fall back to local authority; and
367
+ synthetic NS/SOA is installed only after activation, never on intent. That work
368
+ needs a persisted status field, a release-version-matched `ts_migrations` step,
369
+ new ops API methods, and UI — it is a feature program, not a defect fix.
370
+
371
+ Why this matters: **smartdns marks any answer a registered handler produces as
372
+ authoritative (`aa`) regardless of `authoritativeZones`.** See
373
+ `classes.dnsserver.js`: a handler hit sends `'answer'`, a miss inside an
374
+ authoritative zone sends `'authoritativeNegative'`, and a miss outside sends
375
+ `'refused'`. So registering *any* handler for a zone is a public claim of
376
+ authority over it, and dcrouter listens on a public UDP/TCP 53.
377
+
378
+ Enforcement points:
379
+
380
+ - `RouteConfigManager.createRoute` / `updateRoute` — refuse an enabled route whose
381
+ `action.tls.certificate === 'auto'` covers an unverified hostname. Disabling is
382
+ never gated. Already-stored routes are **surfaced** as
383
+ `unverified-domain-ownership` route warnings plus an `error` log, not refused,
384
+ because refusing at startup would take down routes that are serving fine.
385
+ - `DnsManager.registerAuthoritativeZoneDefaults` — refuse the generated apex NS
386
+ handler for an unverified zone; `createDcrouterDomain` / `migrateToDcrouter`
387
+ derive `authoritative` from the verdict instead of hard-coding `true`.
388
+ - `DnsServerRuntime.syncPrivateRouteOverrides` — the private-route A overlay is
389
+ "internal" by intent only, never by mechanism. Ungated, one route was enough to
390
+ make dcrouter publicly hand out an RFC1918 address, authoritatively, for a
391
+ third party's domain.
392
+ - `certProvisionFunction` and `reprovisionCertificateDomain` — refuse before
393
+ starting an ACME order that cannot succeed.
394
+
395
+ ## ACME retry budgets are three independent mechanisms (2026-07-25)
396
+
397
+ Do not assume they share semantics, caps, or re-arm behaviour:
398
+
399
+ | | `SmartAcmeLifecycle` | `CertProvisionScheduler` | SmartProxy `certFailureAt` |
400
+ |---|---|---|---|
401
+ | scope | provider startup | per domain | per domain |
402
+ | increments on | failed `smartAcme.start()` | `certProvisionFunction` throw | `certProvisionFunction` throw |
403
+ | backoff | 5 s→1 h, ±20% jitter | `min(failures², 24 h)` | flat 30 min |
404
+ | cap | 20 attempts, then gives up | **none** — grows forever | n/a |
405
+ | persisted | no | yes (`CertBackoffDoc`) | no (in-memory, private) |
406
+ | re-armed by | `startInBackground()` only: a SmartProxy rebuild, `rearm()`, or a process restart. **Never on a timer.** | time | time, or a successful provision it cannot reach |
407
+
408
+ `classifyAcmeFailure()` (`ts/acme/acme-failure-classification.ts`) decides which
409
+ causes may enter a budget at all. A configuration cause (no managed domain, no
410
+ provider zone, unverified ownership, bad ACME account, CAA) is terminal: it is
411
+ raised as `AcmePermanentFailureError` / `DomainOwnershipError` and never consumes
412
+ a budget. Unrecognised causes stay **transient** on purpose — guessing "permanent"
413
+ would strand domains a retry would have fixed.
414
+
3
415
  ## smartmta Migration (2026-02-11)
4
416
 
5
417
  ### Overview
package/readme.md CHANGED
@@ -25,7 +25,7 @@ Highlights:
25
25
  | --- | --- |
26
26
  | Proxying | SmartProxy routes for HTTP, HTTPS, TCP, SNI, TLS termination, passthrough, backend forwarding, source policies, rate limits, and browser challenges |
27
27
  | Route ownership | Constructor routes, generated email/DNS routes, and API-created routes with explicit origins |
28
- | DNS | Authoritative scopes, generated NS records, static DNS records, provider-backed domains, and DoH endpoints |
28
+ | DNS | Delegation-verified authoritative zones, generated NS records, static DNS records, provider-backed domains, and DoH endpoints |
29
29
  | Email | UnifiedEmailServer startup, email-domain management, route-backed delivery actions, received mail operations, managed app address bindings, and outbound SMTP submission identities |
30
30
  | Certificates | ACME config, managed-domain DNS-01 challenges, HTTP-01 fallback, stored certificate metadata, provisioning backoff, and certificate status reporting |
31
31
  | Edge access | Remote ingress hub, edge registrations, derived edge ports, pushed firewall rules, VPN-only route access |
@@ -116,8 +116,7 @@ Bootstrap behavior:
116
116
  | `emailOutboundMode` | Outbound SMTP mode. Defaults to `direct`; `remoteIngress` routes outbound SMTP through a RemoteIngress egress edge. |
117
117
  | `emailPortConfig` | External-to-internal email port mapping and received-email storage path. |
118
118
  | `tls` | Legacy/static TLS and ACME contact settings used to seed certificate config. |
119
- | `dnsNsDomains` | Nameserver hostnames used for generated NS records and DoH routes. |
120
- | `dnsScopes` | Authoritative domains served by the embedded DNS server. |
119
+ | `dnsNsDomains` | Nameserver hostnames used for generated NS records and DoH routes. A zone becomes authoritative by having its public NS records observed naming one of these. |
121
120
  | `dnsRecords` | Constructor-defined DNS records. |
122
121
  | `publicIp` / `proxyIps` | IPs used for generated A records and proxy-aware DNS exposure. |
123
122
  | `dbConfig` | Smartdata persistence via embedded LocalSmartDb or external MongoDB. |
@@ -130,7 +129,7 @@ Bootstrap behavior:
130
129
  Important runtime behavior:
131
130
 
132
131
  - `dbConfig.enabled` defaults to enabled. Without `mongoDbUrl`, dcrouter uses embedded LocalSmartDb.
133
- - If the DB is disabled, constructor-defined proxy traffic can still run, but persistent API routes, tokens, managed domains, and stored certificate state are unavailable.
132
+ - If the DB is disabled, constructor-defined proxy traffic can still run, but persistent API routes, tokens, managed domains, and stored certificate state are unavailable. The embedded DNS server is also skipped entirely, because DNS authority is delegation-verified database state — the DoH routes are still generated, but nothing answers behind them.
134
133
  - Qualifying HTTPS forward routes on port `443` are HTTP/3-augmented unless `http3.enabled === false` or the route opts out.
135
134
  - DNS-over-HTTPS routes are generated on the first `dnsNsDomains` entry at `/dns-query` and `/resolve`.
136
135
  - Email listener ports can be remapped internally, for example public `25`, `587`, and `465` to unprivileged internal ports.
@@ -176,6 +175,47 @@ dcrouter keeps generated and operator-created routes separate so automation can
176
175
 
177
176
  System routes are persisted with stable `systemKey` values. Ordinary operator-created API routes are editable through generic route CRUD. Routes carrying managed ownership metadata, including Special Forwards and gateway-client routes, reject generic structural updates and deletion so their owning workflow keeps canonical match, priority, source-policy, and ownership fields intact.
178
177
 
178
+ ## DNS Authority
179
+
180
+ Which zones the embedded DNS server may answer for authoritatively is database
181
+ state, not deployment configuration. There is no `dnsScopes` option.
182
+
183
+ A zone enters the authority set exactly one way: its **public delegation must
184
+ name one of `dnsNsDomains`**, observed through a single DNS-over-HTTPS lookup
185
+ against a public resolver. The system resolver is never used for this — on a
186
+ dcrouter host it may be dcrouter itself, which would answer with the very NS
187
+ records dcrouter generated and make the proof self-confirming. An ops-API caller
188
+ cannot repoint somebody else's delegation, so writing the record is not the same
189
+ as manufacturing the proof.
190
+
191
+ | Operation | Scope | Effect |
192
+ | --- | --- | --- |
193
+ | `getDnsAuthority` | `dns-authority:read` | The authority set, its evidence, and whether it was readable. |
194
+ | `probeDnsAuthorityZone` | `dns-authority:read` | Read-only delegation probe. Never mutates. |
195
+ | `verifyDnsAuthorityZone` | `dns-authority:write` | Claims a zone, only against a `delegated` verdict. |
196
+ | `revokeDnsAuthorityZone` | `dns-authority:write` | Drops a zone. Every zone is revocable. |
197
+ | `getDnsAuthorityDrift` | `dns-authority:read` | Advisory comparison of claimed authority against reality. |
198
+
199
+ Writes accept an admin identity or an API token carrying `dns-authority:write`;
200
+ a non-admin identity is refused. Probes return three verdicts, never two: `delegated`, `not-delegated`, and
201
+ `undeterminable`. A timeout is not evidence that a zone is not ours, so a
202
+ mutation refuses on `undeterminable` rather than assuming either way.
203
+
204
+ Claiming or revoking a zone takes effect in-process, with no restart: it moves
205
+ the running DNS server's authoritative zone set, its generated apex NS records,
206
+ route certificate warnings, and the private-route overlay together.
207
+
208
+ **Cold start.** A database with no authority document is a legitimate state, and
209
+ it means dcrouter is authoritative for nothing and REFUSES every query. The DNS
210
+ server still starts — so DoH keeps serving and a zone verified a moment later
211
+ takes effect immediately — and the condition is logged at `error` alongside a
212
+ startup drift audit listing every dcrouter-hosted zone delegated to us that is
213
+ not being served.
214
+ An authority document that cannot be *read* is treated differently: the set is
215
+ unknown rather than empty, so the DNS services fail and retry instead of quietly
216
+ revoking every zone. dcrouter itself still comes up — both services are optional
217
+ — so the rest of the router keeps running while DNS stays deliberately down.
218
+
179
219
  ## Route Source Bindings
180
220
 
181
221
  API-created route records pass ordered `metadata.sourceBindings[]` alongside the SmartProxy route config to express source and path policy variants without duplicating whole routes by hand. Each binding points at a source profile id through `sourceProfileRef`. Dashboard presets resolve seeded profile names to ids before saving.
@@ -361,7 +401,9 @@ const router = new DcRouter({
361
401
  },
362
402
  emailOutboundMode: 'remoteIngress',
363
403
  dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
364
- dnsScopes: ['example.com'],
404
+ // Which zones the embedded DNS server answers for is not configured here.
405
+ // A zone earns authority by its public delegation naming dnsNsDomains, and
406
+ // that proof lives in the database — see the "DNS Authority" section above.
365
407
  publicIp: '203.0.113.10',
366
408
  remoteIngressConfig: {
367
409
  enabled: true,
@@ -473,7 +515,7 @@ Supported environment overrides include:
473
515
  | `DCROUTER_BASE_DIR` | Runtime data root. |
474
516
  | `DCROUTER_TLS_EMAIL` / `DCROUTER_TLS_DOMAIN` | TLS/ACME seed settings. |
475
517
  | `DCROUTER_PUBLIC_IP` / `DCROUTER_PROXY_IPS` | Public/proxy IP exposure settings. |
476
- | `DCROUTER_DNS_NS_DOMAINS` / `DCROUTER_DNS_SCOPES` | DNS nameserver and authoritative scope settings. |
518
+ | `DCROUTER_DNS_NS_DOMAINS` | Nameserver hostnames. `DCROUTER_DNS_SCOPES` is no longer honored — DNS authority is delegation-verified database state, and a container still setting it is warned at startup. |
477
519
  | `DCROUTER_EMAIL_HOSTNAME` / `DCROUTER_EMAIL_PORTS` | Email server seed settings. |
478
520
  | `DCROUTER_CACHE_ENABLED` | Enables or disables DB-backed persistence. |
479
521
  | `DCROUTER_MAX_CONNECTIONS`, `DCROUTER_MAX_CONNECTIONS_PER_IP`, `DCROUTER_CONNECTION_RATE_LIMIT` | SmartProxy capacity and rate-limit overrides. |
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '17.10.2',
6
+ version: '18.0.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }