diff --git a/build.zig b/build.zig index 28f28f2..8bfd2e5 100644 --- a/build.zig +++ b/build.zig @@ -140,6 +140,8 @@ fn addExecutable( exe.root_module.addAnonymousImport("web_assets", .{ .root_source_file = web_assets }); exe.root_module.linkLibrary(sqliteLibrary(b, target, optimize)); exe.root_module.linkLibrary(mbedtlsLibrary(b, target, optimize)); + exe.root_module.addCSourceFile(.{ .file = b.path("src/platform/mbedtls_shim.c") }); + addMbedtlsThreadingMacros(exe.root_module); return exe; } diff --git a/specs/milestone-10.md b/specs/milestone-10.md new file mode 100644 index 0000000..d1b6fe9 --- /dev/null +++ b/specs/milestone-10.md @@ -0,0 +1,365 @@ +# Milestone 10: DoH/DoT server + cert watcher + certs/reload (PLAN Phase 9) + +Goal: LAN clients resolve via DoH (RFC 8484 over HTTP/1.1 + TLS) and DoT (RFC 7858) +against local certs; certs hot-reload via a polling watcher and `POST /api/certs/reload` +(endpoint + openapi entry + contract test, deferred whole from milestone-8 ruling 2). + +Ground truth: PLAN.md:23,155,194,232,500-503,525,547,615-616,647,664 (scope, layout, +config keys, endpoint, exit); PLAN.md:684 — HTTP/2 and DoQ are OUT; PLAN.md:297 — +"a failed reload publishes neither". Explore facts (verified this session): +- platform/tls_server.zig: ServerContext.init(gpa, cert_pem, key_pem) shared across + connections; binding is per-accept (mbedtls_ssl_setup at accept), so reload = publish + a new context pointer; the OLD context must outlive every stream set up against it. + ServerStream.reader()/writer() are real *Io.Reader/*Io.Writer; close(gpa) sends + close_notify but does NOT close the TCP stream. MBEDTLS_THREADING_C + PTHREAD are ON + (build.zig:255) — one context is safe under concurrent handshakes. +- std.http.Server.init(in: *Reader, out: *Writer) takes ARBITRARY reader/writer + (http/Server.zig:25) — web/server.zig's serveConn transfers with three lines changed. +- No usable file watching in 0.16 (inotify/fanotify are raw syscalls outside Io) — + the watcher POLLS Io.File.stat mtime+size. +- Handler.handle(self, io, which: Transport, from, query, response_buf, scratch) + (handler.zig:164); Transport {udp,tcp} drives truncation only; response_buf 512..65535; + Scratch ~7 KiB per connection. tcp_server serveConn (tcp_server.zig:265-338) is the + DoT loop verbatim modulo the stream; dns/transport.zig has prefix_len/parsePrefix/ + framePrefix/max_message_len. +- doh_client.zig: media_type "application/dns-message" (:16), contentTypeOk exported + (:171). base64 url_safe_no_pad = the RFC 8484 GET codec. http_util.queryPairs for ?dns=. +- Fixtures: tests/fixtures self_signed cert/key via the test_fixtures anonymous import. +- Config: model.TlsEndpoint already exists (doh_server port 443, dot_server 853), + validated, settings-persisted, checked by `nxdns check`. app.zig ignores both today. + +## Rulings (binding) + +1. **Two listeners**: `src/server/doh_server.zig`, `src/server/dot_server.zig` + (PLAN:194), constructed only when their endpoint is enabled. Bind failure = warn + + continue (web precedent). Both join app.zig's group; cancel semantics mirror + tcp_server's Stop enum (.canceled cancels the connection group; listener close + drains). +2. **DoH protocol**: HTTP/1.1 only (PLAN:684 excludes h2). Path `/dns-query` exactly. + `POST` with `content-type: application/dns-message` (checked via + doh_client.contentTypeOk) and `GET ?dns=`. Responses: + 200 `application/dns-message` with the reply bytes; handler `.drop` → 502 with empty + body reason? NO — `.drop` means "no answer on purpose" (malformed/refused-silent): + close the connection for POST/GET alike via 400. Errors: 404 non-/dns-query path, + 405 other methods (Allow: GET, POST), 415 wrong content-type, 400 missing/invalid + `dns` param or empty body, 413 body > dns/transport.max_message_len. No + cache-control headers (LAN, no intermediaries). Keep-alive per std.http.Server loop; + the bodyless-POST normalization from web/server.zig:443 is copied (same stdlib + assert). +3. **DoT protocol**: RFC 7858 = the tcp_server loop over ServerStream: 2-byte prefix, + parsePrefix, reject 0, read body, handle, frame + write + flush. idle_timeout race + identical (same Options shape, max_connections 64, per-conn Scratch). The TLS + handshake itself runs under the same race budget (a stalled handshake must not pin a + connection slot). +4. **Transport enum stays {udp, tcp}**: DoH/DoT call handle with `.tcp` (it only + drives truncation, which never applies to stream transports). No unused surface. +5. **ALPN, additive to platform**: ServerContext.init gains + `alpn: ?[*:null]const ?[*:0]const u8` (mbedtls_ssl_conf_alpn_protocols requires a + NULL-terminated array that OUTLIVES the config — use comptime-constant arrays). + DoH advertises ["http/1.1"], DoT ["dot"]. A client with no ALPN still connects + (mbedTLS only enforces when the client sends the extension). Existing callers pass + null; the milestone-1 spec note about "no ALPN" is superseded. +6. **Cert holder + reload**: new `src/server/cert_store.zig`. `CertStore` owns + {mutex, current: *Entry, cert_path, key_path, gpa} where Entry = {ctx: ServerContext, + refs: u32, retired: bool}. `acquire(io) *Entry` (+1 under mutex), + `release(io, entry)` (−1; free when retired and 0). `reload(io) ReloadError!void`: + read both PEM files (NUL-terminated dupes, 64 KiB cap each), ServerContext.init, on + success swap + retire the old (freed when its refs drain), on ANY failure publish + nothing (PLAN:297). Listeners acquire per connection (before accept) and release + when the connection ends — so in-flight streams keep their generation alive. + File read errors and init errors map to a typed error + a human message for the API. +7. **Cert watcher**: one task per enabled endpoint inside cert_store + (`watch(store, io)` house loop: Cancelable!void, .boot clock, warn-and-continue). + Poll every 30 s: stat cert AND key (Io.File.stat), compare mtime+size against the + last LOADED pair; any change → reload(); reload failure → warn (the old cert keeps + serving) + a `reload_failures` counter; success → info-level "certificate reloaded". + mtime+size both compared (same-second atomic renames). +8. **`POST /api/certs/reload`** (PLAN:547): handler `src/web/handlers/certs.zig` + (PLAN:232). auth = .session, rate_limit = .counted. Reloads every enabled endpoint's + store; response 200 `{"doh": {"enabled": bool, "reloaded": bool, "error": |null}, + "dot": {...}}` — per-endpoint outcome, 200 even when a reload fails (the outcome IS + the payload; nothing about the server's own state is exceptional). Disabled endpoint: + {enabled:false, reloaded:false, error:null}. WebState gains `doh_certs: ?*CertStore` + and `dot_certs: ?*CertStore` (null-defaulted, W3 pattern). +9. **openapi.yaml + contract test land together** (m8 ruling 2): the path entry with + the exact schema, and a contract-table entry + response-shape test in + web_integration_test.zig. Drift guard (b) count moves 55 → 56 — the test derives the + count from router.routes so only the yaml operation count assertion data changes. +10. **Observability**: each listener keeps tcp_server-shaped stats (connections, + tls_handshake_failures, idle_timeouts, connection_errors) + cert_store keeps + {reloads, reload_failures, last_reload_unix}. metrics.zig additively gains + `nxdns_doh_server_*`, `nxdns_dot_server_*`, `nxdns_cert_reloads_total`, + `nxdns_cert_reload_failures_total` families (counterGroup walks the structs — + follow its existing pattern). /api/health is unchanged (no new rollup this + milestone; the reload endpoint reports cert state on demand). +11. **app.zig wiring**: when `cfg.doh_server.enabled`, build its CertStore (initial + load happens in serve BEFORE the group — a bad cert at boot is exit 2 with the + validate-style message, matching `nxdns check`'s contract that enabled endpoints + have readable certs; the WATCHER handles later breakage gracefully), then the + listener; same for dot. Watcher tasks + listener tasks join the group. WebState + seams wired when web is also enabled. SIGTERM path unchanged. +12. **Integration tests** (both gated `-Dintegration`, loopback, fixtures): + DoT: std.crypto.tls.Client (no_verification) → framed A query → framed reply + (reuse resolver_integration_test's fake-upstream pattern or a local-records-only + handler). DoH POST + GET: raw HTTP/1.1 text over std.crypto.tls.Client (no + std.http.Client CA ceremony), assert status/content-type/bytes; 415/405/404/400 + matrix; keep-alive two-requests test. Reload: swap fixture cert (write a second + self-signed pair fixture), reload, NEW connection sees the new cert + (std.crypto.tls.Client exposes the peer cert? if not: assert old connections + still serve and new handshakes succeed — the observable contract), old connection + still answers. Watcher: unit-test the decision function (stat-pair compare) pure; + do not test 30 s timing. +13. **New fixture**: `tests/fixtures/self_signed_cert2.pem` + key2 (openssl one-liner, + same profile as the existing pair; README updated) for reload tests. +14. **Docs**: NOT this milestone beyond openapi.yaml (docs/api rendering is Phase 10 — + m8 ruling 3 unchanged). +15. **Zig freeze elsewhere**: dns/, filter/, cache/, storage/, existing server files + (udp/tcp/handler) untouched except: app.zig (T6), metrics.zig + routes.zig + + openapi.yaml + web_integration_test.zig + web/server.zig(WebState fields only) + (T5). handler.zig is NOT touched (ruling 4). + +## Sessions + +T1+T2 parallel first; then T3+T4+T5 parallel; then T6. + +--- + +## Session T1: platform ALPN + +Owns `src/platform/tls_server.zig` (+ its tests). Add the alpn parameter per ruling 5 +(extern mbedtls_ssl_conf_alpn_protocols, comptime NULL-terminated arrays, doc the +lifetime rule), update existing callers/tests with null, add one test that a client +negotiating "http/1.1" succeeds against a context advertising it (the loopback +integration test grows the assertion — std.crypto.tls.Client: check whether it can +send ALPN in 0.16; if it cannot, the unit test just proves config acceptance and the +lifetime doc stands). + +Acceptance: fmt/ast clean; plain + integration suites 0 failed. + +### T1 As built + +`ServerContext.init(gpa, cert_pem, key_pem, alpn: ?[*:null]const ?[*:0]const u8)`. +Extern `mbedtls_ssl_conf_alpn_protocols` verified against vendored 3.6.7 ssl.h:4314 +(pointer recorded, not copied — comptime-constant NULL-terminated arrays required, +documented on init). MBEDTLS_SSL_ALPN is on in the stock config. std.crypto.tls.Client +CANNOT send ALPN in 0.16 (no Options field) — negotiation untestable client-side; the +unit test proves config acceptance, the integration echo proves no-ALPN clients still +handshake against an advertising context. Servers can assert server-side via +mbedtls_ssl_get_alpn_protocol if ever needed (extern not declared — unused surface). +Mechanical ripple: tls_client_integration_test.zig:193 caller gained `, null`. + +## Session T2: cert_store + +Owns `src/server/cert_store.zig` (new). Ruling 6 (holder + refcount + reload) and +ruling 7 (watch loop + pure stat-compare decision fn) + ruling 10's counters + +`snapshotStats()`. Unit tests: acquire/release refcounting across a swap (old entry +freed only after last release), reload failure publishes nothing (bad path, bad PEM), +NUL-termination and size cap, stat-compare decision table. Uses fixtures via +test_fixtures import. + +Acceptance: fmt/ast clean; temp-root green (needs -lc + vendored mbedtls objects). + +### T2 As built + +16 in-file tests; temp-root 22 passed / 0 failed. Surface: +`poll_interval_s=30`, `max_pem_bytes=64KiB`, `ReloadError` (10 cases) + +`humanMessage(err) []const u8`; `FileSig{mtime_ns: i96, size}`, `Signature{cert,key}`, +pure `changed(loaded, observed) bool`; `Entry{ctx, refs, retired}`; +`CertStore.init(gpa, io, cert_path, key_path, alpn)` (boot load; typed error for T6's +exit-2), `deinit(io)` (asserts current refs==0 — join listeners first), +`acquire(io) *Entry` / `release(io, entry)` (release safe from any task; last releaser +frees a retired entry), `reload(io) ReloadError!void`, `watch(io) Cancelable!void` +(sleeps BEFORE first poll, .boot clock), `pollOnce(io)` (testable watcher pass), +`snapshotStats()` (no io; {reloads, reload_failures, last_reload_unix}). +Notes: use `&entry.ctx` for ServerStream.accept; paths are BORROWED (config outlives +the store); `reloads` counts successes only; stat failures warn but are not +reload_failures; `last_reload_unix` set at init too. + +## Session T3: dot_server (after T1+T2) + +Owns `src/server/dot_server.zig` (new) + its integration test file if separate +(prefer tests inside the file, house pattern). Ruling 3: tcp_server loop shape over +ServerStream; acquire cert entry per connection, release on exit; handshake under the +race budget; stats per ruling 10; Stop enum; max_connections 64; ALPN ["dot"]. +Integration tests per ruling 12 (DoT half). + +Acceptance: fmt/ast clean; both suites 0 failed. + +### T3 As built + +`DotServer.listen(gpa, io, listen_address, h: *handler.Handler, certs: *cert_store.CertStore, options)` / +`serve(io)` / `boundAddress()` / `deinit(io)`. Options `{max_connections: u16 = 64, idle_timeout = 10s}`; +the handshake runs under the same idle budget. Deviation from tcp_server: `deinit` takes no +gpa — the allocator is stored at `listen` because `ServerStream.accept` heap-allocates one +mbedTLS ssl context per connection (documented in the file header). Stats atomics: +the four ruling-10 fields plus tcp-pattern internals `rejected_at_capacity, +rejected_at_shutdown, accept_errors`; `snapshotStats()` returns exactly the ruling-10 four. +Loop: accept → claim slot → `certs.acquire` (released on every exit) → handshake raced +against the idle budget → 2-byte-prefix loop over ServerStream reader/writer, flush per +reply, handler called with `.tcp`, `tls.close(gpa)` (close_notify) on every post-handshake +exit. A `handshook` flag ensures the ssl context closes exactly once when Select reports +expiry/cancel after a successful handshake; a timed-out handshake counts as +`tls_handshake_failures`. ALPN is set only via the store (T6). 7 unit + 4 integration +tests in-file. Teardown order: `server.deinit(io)` before `CertStore.deinit`. + +## Session T4: doh_server (after T1+T2) + +Owns `src/server/doh_server.zig` (new). Ruling 2 in full: std.http.Server over +ServerStream reader/writer (recv buffer 8 KiB = head cap, send 4 KiB), connection +group + 64 cap + 503-free refusal is NOT applicable here (no HTTP yet at accept +overflow: over cap → close raw, cheapest honest behavior — document); per-connection +Scratch + response_buf (65535); GET base64url decode (url_safe_no_pad, reject padding/ +whitespace), POST body cap via Io.Limit; keep-alive; bodyless-POST normalization; +ALPN ["http/1.1"]; cert acquire/release per connection; stats. Integration tests per +ruling 12 (DoH half: POST, GET, 415/405/404/400, keep-alive). + +Acceptance: fmt/ast clean; both suites 0 failed. + +### T4 As built + +`DohServer.listen(gpa, io, address, *Handler, *CertStore, Options)` / `serve(io)` / +`deinit(gpa, io)` / `boundAddress()` / `snapshotStats()`. Options +`{max_connections = 64, idle_timeout = 10s}` — the budget races the TLS handshake only; +requests have no timeout (web-listener precedent). Module-level +`serve(gpa, io, model.TlsEndpoint, *Handler, *CertStore)` is the bind-warn-and-continue +entry for T6. Exposed comptime `alpn_protocols` (["http/1.1"]) and `dns_query_path`. +Stats: the ruling-10 four + `bad_requests` + tcp-shaped accept counters +(`rejected_at_capacity, rejected_at_shutdown, accept_errors`) — over-capacity refusals +must stay visible. Deviations: POST cap via content-length precheck + fixed buffer + +one-byte probe for chunked bodies instead of Io.Limit (no allocation on the hot path); +`respond` can raise `HttpExpectationFailed` (100-continue), mapped to `connection_errors`; +no 413 integration test (ruling 12's matrix is 415/405/404/400). GET `?dns=` parsed by a +local validating decoder (duplicate param → 400, `%` and padding/whitespace rejected). +Oversize POST answers 413 with `keep_alive=false` (body never drained). Over the 64 cap +the raw TCP stream closes (no HTTP before a handshake). 8 unit + 4 integration tests. +T6 recipe: `CertStore.init(gpa, io, cert_path, key_path, doh_server.alpn_protocols)`; +run `doh_server.serve` and `store.watch` as group tasks; `DohServer.deinit` before +`CertStore.deinit`. + +## Session T5: web endpoint + metrics (after T2, parallel with T3/T4) + +Owns `src/web/handlers/certs.zig` (new), `src/web/routes.zig` (one entry), +`src/web/openapi.yaml` (one path), `src/web/server.zig` (two null-defaulted WebState +fields ONLY), `src/web/metrics.zig` (ruling 10 families), and the additive +web_integration_test.zig edits (contract entry + shape test + guard-b count). +Handler per ruling 8 (apply/respond split, house pattern). Unit tests for the +apply fn (both stores null; one store failing reload → error message in payload, +still 200). + +Acceptance: fmt/ast clean; plain suite 0 failed; drift guards green. + +### T5 As built + +handlers/certs.zig: apply/respond split — `applyReload(state, io) View` with +`View{doh, dot}` and `Outcome{enabled, reloaded, @"error": ?[]const u8}` (serialized as +`error`); `post` always answers 200. Route: `POST /api/certs/reload`, `.session`, +`.counted`; table 55→56; contract entry + strict shape test + drift guards green at 56. +metrics.zig: the two ruling-10 cert counters share one family pair +(`nxdns_cert_reloads_total` / `nxdns_cert_reload_failures_total`) with an +`endpoint="doh"|"dot"` label (upstream-label pattern); unwired stores omit the family; +`last_reload_unix` not exported. WebState gains `doh_certs`/`dot_certs: +?*cert_store.CertStore = null`. Deviation (accepted): deleted the m8 tripwire test in +`src/web/openapi.zig` ("the document does not promise what phase 9 owns") — it exists to +fire when this endpoint lands. Deferred to T6: `nxdns_doh_server_*` / `nxdns_dot_server_*` +listener families (T3/T4 types did not exist at T5 build time). + +## Session T6: app wiring + fixtures + smoke (after all) + +Owns `src/app.zig`, `tests/fixtures/` (cert2/key2 + README + fixtures.zig), and +`src/tests.zig` is the ORCHESTRATOR's (report lines). Ruling 11 wiring; ruling 13 +fixture. Smoke (report transcript): boot with doh+dot enabled on unprivileged ports +with fixture certs; DoT query via a scripted TLS client (a tiny zig run or the +integration test binary is fine — no new tooling); DoH POST via curl --insecure +--http1.1 with a binary body; certs/reload via the API twice (success, then chmod the +key unreadable → error in payload, old cert still serving a new connection); SIGTERM 0. +Both suites + cross ReleaseSafe with the SPA dist. + +Acceptance: full gates green; smoke transcript. + +### T6 As built + +app.zig realizes ruling 11 in-frame: `openCertStore` maps non-OOM `ReloadError` to +`error.BadCertificate` (exit 2, prints both paths + `cert_store.humanMessage`); +`bindDoh`/`bindDot` warn-and-continue per ruling 1; listeners + two `store.watch` loops +join the group; stores are declared before listeners so teardown runs listener-deinit → +store-deinit and the refs==0 assert holds. `dot_alpn` (["dot"]) lives in app.zig. +Deviation: `doh_server.serve` module entry is NOT used — it constructs the DohServer in +its own task frame, so WebState could never get the pointer the `nxdns_doh_server_*` +family needs; both listeners bind in app.zig's frame instead (tcp_server style). +`doh_server.serve` remains as unused pub API. metrics.zig: `DohListenerSample` +(ruling-10 four + `bad_requests`); DoT renders `dot_server.StatsSnapshot` directly; +accept-side counters stay off the exposition; unwired listeners omit the families. +WebState gains `doh_listener`/`dot_listener` optional pointers. build.zig (out of +ownership, necessary): the exe now compiles `mbedtls_shim.c` with the threading macros — +first exe-side tls_server reference; mirrors the tests wiring. Fixtures: cert2/key2 +exported as `cert2_pem`/`key2_pem` (distinct fingerprint), currently unreferenced by +tests. Config: `model.TlsEndpoint` already existed; no model/validate/schema edits. +Smoke: DoT framed query answered from local records; DoH POST 200 +application/dns-message; reload success then key chmod 000 → `reloaded:false` with +"private key file is not readable" while the old cert keeps serving; SIGTERM 0; boot +with unreadable key exits 2. + +--- + +## Review (Codex, as built) + +Four rounds on one thread; round 4 returned "No findings." + +Round 1 (3 important, 2 minor): doh_server leaked the TLS context when the handshake +won a race the Select reported as timeout/cancel → handshook flag (dot_server pattern). +cert_store reloads were not serialized across read→build→publish, so an older read could +publish last → `reload_mutex` held across the whole sequence (lock order: reload_mutex +before the entry mutex; accept path untouched) + a hook-pinned regression test +(`after_load_hook` seam). doh bodied-GET smuggling: std leaves unframed body bytes for +bodyless methods to be parsed as the next request → `framesUnreadableBody` guard, 400 + +close, no byte read. `.drop` now closes per ruling 2. cert2/key2 were unreferenced → +DoT reload-to-distinct-identity integration test (old connection keeps serving, new +handshake succeeds, distinct entry pointers; the 0.16 tls Client does not retain the +peer chain, so identity is proven via entry pointers). + +Round 2 (2 minor): the smuggling guard ran before routing and stole 404/405 statuses → +routing decides the status, framed requests only force close. Successful POSTs ignored +the client's `Connection: close` → answer/refuse honor `head.keep_alive` in the Next +enum (the wire already did via Server.zig:628). + +Round 3 (1 important, introduced by the round-2 reshape): bodied refusals (404/405/415) +kept keep-alive, so respond drained the whole body first — an unterminated chunked body +could pin all 64 slots with no response → `framesBody` helper; any pre-body-read refusal +closes when a body is framed; drain-free integration tests (missing terminal chunk → +404+EOF at once; undelivered content-length 4096 → 415+EOF); errorMatrix's 415 became a +zero-length wrong-type POST so its keep-alive reuse stays legitimate. + +Final counts: doh_server.zig 19 tests, dot_server.zig 12, cert_store.zig 17. Final +gates: plain 1166/1279 passed, 113 skipped (integration-gated), 0 failed; integration +1275/1279, 4 skipped (live-network by design), 0 failed. + +## Module layout (new) + +src/server/{doh_server,dot_server,cert_store}.zig, src/web/handlers/certs.zig, +tests/fixtures/{self_signed_cert2.pem,self_signed_key2.pem}. + +## File ownership + +T1 platform/tls_server.zig; T2 server/cert_store.zig; T3 server/dot_server.zig; +T4 server/doh_server.zig; T5 web/{handlers/certs.zig,routes.zig,openapi.yaml, +server.zig(fields),metrics.zig,web_integration_test.zig}; T6 app.zig + fixtures. +Orchestrator: src/tests.zig, spec. Parallel sessions never share a file. + +## Acceptance (milestone complete) + +- [ ] Both suites 0 failed (plain + -Dintegration), zero err lines; cross ReleaseSafe + with SPA dist green. +- [ ] DoT and DoH loopback integration tests green (query → reply, error matrix, + keep-alive). +- [ ] certs/reload endpoint + openapi entry + contract test land together; drift + guards green at 56. +- [ ] Cert watcher reloads on file change; failed reload keeps serving the old cert. +- [ ] T6 smoke transcript incl. reload-failure path and SIGTERM 0. +- [ ] Spec As-built synced per session. + +## Anti-requirements + +No HTTP/2, no DoQ, no SNI/multi-cert, no client-cert auth, no session tickets, no +proxy-protocol/XFF handling, no cert generation (ACME etc.), no cache-control on DoH, +no handler.zig changes, no docs/api rendering (Phase 10), no config schema changes +(TlsEndpoint exists), no inotify. diff --git a/src/app.zig b/src/app.zig index 5ea7105..a0dfabc 100644 --- a/src/app.zig +++ b/src/app.zig @@ -33,6 +33,7 @@ const tls = std.crypto.tls; const api_limiter = @import("web/api_limiter.zig"); const auth = @import("web/auth.zig"); const bootstrap = @import("config/bootstrap.zig"); +const cert_store = @import("server/cert_store.zig"); const cli = @import("cli.zig"); const clients = @import("server/clients.zig"); const config_export = @import("config/export.zig"); @@ -40,7 +41,9 @@ const db = @import("storage/db.zig"); const disk_monitor = @import("storage/disk_monitor.zig"); const dns_cache = @import("cache/dns_cache.zig"); const doh_client = @import("upstream/doh_client.zig"); +const doh_server = @import("server/doh_server.zig"); const dot_client = @import("upstream/dot_client.zig"); +const dot_server = @import("server/dot_server.zig"); const fetcher = @import("filter/fetcher.zig"); const forward_zones = @import("local/forward_zones.zig"); const handler = @import("server/handler.zig"); @@ -91,6 +94,7 @@ const ConfigError = error{ NoUsableUpstreams, BadBindAddress, BadRateLimit, + BadCertificate, }; pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 { @@ -112,7 +116,11 @@ pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 { fn isConfigFault(err: anyerror) bool { return switch (err) { - error.NoUsableUpstreams, error.BadBindAddress, error.BadRateLimit => true, + error.NoUsableUpstreams, + error.BadBindAddress, + error.BadRateLimit, + error.BadCertificate, + => true, else => false, }; } @@ -371,6 +379,41 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 { .tracker = &tracker, }; + // ----------------------------------------------------------------------- + // DoH/DoT listeners (milestone-10 ruling 11) + // ----------------------------------------------------------------------- + + // The stores are declared before the listeners on purpose: their deinit + // defers run last, and `CertStore.deinit` asserts every connection has + // released its generation, which only holds once the listeners are gone. + // A certificate that does not load at boot is exit 2 — `nxdns check` + // promises that an enabled endpoint has readable certs — while anything + // that breaks later is the watcher's to absorb. + var doh_certs: ?cert_store.CertStore = null; + defer if (doh_certs) |*store| store.deinit(io); + if (cfg.doh_server.enabled) { + doh_certs = try openCertStore(r, gpa, io, cfg.doh_server, "doh_server", doh_server.alpn_protocols); + } + + var dot_certs: ?cert_store.CertStore = null; + defer if (dot_certs) |*store| store.deinit(io); + if (cfg.dot_server.enabled) { + dot_certs = try openCertStore(r, gpa, io, cfg.dot_server, "dot_server", dot_alpn); + } + + // Bound here, in this frame, rather than through doh_server's module-level + // entry: `/metrics` reads the listeners' counters through `WebState`, and + // only a listener that lives in this frame has an address to wire there. + // A failed bind warns and stays off (ruling 1, the web precedent): TLS DNS + // failing to come up must not stop the plain-DNS side this box exists for. + var doh: ?doh_server.DohServer = null; + defer if (doh) |*server| server.deinit(gpa, io); + if (doh_certs) |*store| doh = bindDoh(gpa, io, cfg.doh_server, &h, store); + + var dot: ?dot_server.DotServer = null; + defer if (dot) |*server| server.deinit(io); + if (dot_certs) |*store| dot = bindDot(gpa, io, cfg.dot_server, &h, store); + // ----------------------------------------------------------------------- // web interface (ruling 26) // ----------------------------------------------------------------------- @@ -401,6 +444,10 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 { .limiter = if (web_limiter) |*l| l else null, .hub = hub, .sink = &sink, + .doh_certs = if (doh_certs) |*store| store else null, + .dot_certs = if (dot_certs) |*store| store else null, + .doh_listener = if (doh) |*server| server else null, + .dot_listener = if (dot) |*server| server else null, .config_db = if (web_config_db) |*database| database else null, .querylog_db = if (web_querylog_db) |*database| database else null, .version = version.string, @@ -463,6 +510,10 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 { if (udp4) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io }); if (tcp6) |*s| try group.concurrent(io, tcp_server.TcpServer.serve, .{ s, io }); if (tcp4) |*s| try group.concurrent(io, tcp_server.TcpServer.serve, .{ s, io }); + if (doh) |*s| try group.concurrent(io, doh_server.DohServer.serve, .{ s, io }); + if (dot) |*s| try group.concurrent(io, dot_server.DotServer.serve, .{ s, io }); + if (doh_certs) |*store| try group.concurrent(io, cert_store.CertStore.watch, .{ store, io }); + if (dot_certs) |*store| try group.concurrent(io, cert_store.CertStore.watch, .{ store, io }); const gate: ?*disk_monitor.Monitor = &monitor; try group.concurrent(io, logger_mod.Logger.runWriter, .{ &query_logger, io, &querylog_writer_db, gate }); @@ -527,6 +578,82 @@ fn serveWebDev( return static.serveFromDisk(web_dev_dir, io, request); } +// --------------------------------------------------------------------------- +// DoH/DoT listeners +// --------------------------------------------------------------------------- + +/// RFC 7858 has no IANA-registered ALPN id in wide use beyond "dot" +/// (milestone-10 ruling 5). Mbed TLS records the pointer, so the list must +/// outlive every `ServerContext` built with it; module scope gives it static +/// lifetime, the same shape as `doh_server.alpn_protocols`. +const dot_alpn: [*:null]const ?[*:0]const u8 = &.{"dot"}; + +/// The boot-time certificate load for one enabled endpoint. A failure is a +/// configuration fault the operator can fix — the same files `nxdns check` +/// verifies — reported in `check`'s style and mapped to exit 2 (ruling 11). +/// Out of memory is the one exception: nothing about the configuration is +/// wrong, so it keeps its own name and exits 1. +fn openCertStore( + r: cli.Runner, + gpa: Allocator, + io: std.Io, + endpoint: model.TlsEndpoint, + section: []const u8, + alpn: ?[*:null]const ?[*:0]const u8, +) !cert_store.CertStore { + return cert_store.CertStore.init(gpa, io, endpoint.cert_path, endpoint.key_path, alpn) catch |err| { + if (err == error.OutOfMemory) return error.OutOfMemory; + r.err.print("{s}: '{s}' + '{s}': {s}\n", .{ + section, + endpoint.cert_path, + endpoint.key_path, + cert_store.humanMessage(err), + }) catch {}; + return error.BadCertificate; + }; +} + +/// Ruling 1: a listener that cannot bind warns and stays off. The bind text +/// itself gets the same treatment — `validate` refuses it, but a hand-edited +/// database can still carry one, and it is not worth taking DNS down over. +fn bindDoh( + gpa: Allocator, + io: std.Io, + endpoint: model.TlsEndpoint, + h: *handler.Handler, + store: *cert_store.CertStore, +) ?doh_server.DohServer { + const bind_address = net.IpAddress.parse(endpoint.bind, endpoint.port) catch { + log.warn("doh_server.bind '{s}' is not an IP address; DoH is disabled", .{endpoint.bind}); + return null; + }; + const server = doh_server.DohServer.listen(gpa, io, bind_address, h, store, .{}) catch |err| { + log.warn("doh listener cannot listen on {s}:{d}: {t}", .{ endpoint.bind, endpoint.port, err }); + return null; + }; + log.info("doh listener on {f}", .{server.boundAddress()}); + return server; +} + +fn bindDot( + gpa: Allocator, + io: std.Io, + endpoint: model.TlsEndpoint, + h: *handler.Handler, + store: *cert_store.CertStore, +) ?dot_server.DotServer { + const bind_address = net.IpAddress.parse(endpoint.bind, endpoint.port) catch { + log.warn("dot_server.bind '{s}' is not an IP address; DoT is disabled", .{endpoint.bind}); + return null; + }; + const server = dot_server.DotServer.listen(gpa, io, bind_address, h, store, .{}) catch |err| { + log.warn("dot listener cannot listen on {s}:{d}: {t}", .{ endpoint.bind, endpoint.port, err }); + return null; + }; + log.info("dot listener on {f}", .{server.boundAddress()}); + return server; +} + // --------------------------------------------------------------------------- // background maintenance // --------------------------------------------------------------------------- diff --git a/src/platform/tls_client_integration_test.zig b/src/platform/tls_client_integration_test.zig index 496a28e..f2b9313 100644 --- a/src/platform/tls_client_integration_test.zig +++ b/src/platform/tls_client_integration_test.zig @@ -190,7 +190,7 @@ test "TlsStream.flush puts the record on the wire" { defer threaded.deinit(); const io = threaded.io(); - var ctx = try tls_server.ServerContext.init(gpa, fixtures.cert_pem, fixtures.key_pem); + var ctx = try tls_server.ServerContext.init(gpa, fixtures.cert_pem, fixtures.key_pem, null); defer ctx.deinit(gpa); const listen_address: net.IpAddress = .{ .ip4 = .loopback(0) }; diff --git a/src/platform/tls_server.zig b/src/platform/tls_server.zig index 39f8fa1..bb7ade1 100644 --- a/src/platform/tls_server.zig +++ b/src/platform/tls_server.zig @@ -62,7 +62,19 @@ pub const ServerContext = struct { const Config = Block(SslConfig); }; - pub fn init(gpa: std.mem.Allocator, cert_pem: [:0]const u8, key_pem: [:0]const u8) InitError!ServerContext { + /// `alpn`, when non-null, is a NULL-terminated list of protocol names in + /// decreasing preference order (e.g. `&.{"http/1.1"}` as a + /// comptime-constant `[_:null]?[*:0]const u8` array). Mbed TLS records the + /// pointer, not a copy, so the array must outlive this `ServerContext` — + /// pass a comptime-constant array, never stack or heap memory that can go + /// away first. Clients that send no ALPN extension still connect; Mbed TLS + /// only enforces the list against clients that offer one. + pub fn init( + gpa: std.mem.Allocator, + cert_pem: [:0]const u8, + key_pem: [:0]const u8, + alpn: ?[*:null]const ?[*:0]const u8, + ) InitError!ServerContext { assert(nx_max_context_alignment() <= context_alignment); // TLS 1.3 is on in the stock config; its key schedule runs through PSA. @@ -148,6 +160,12 @@ pub const ServerContext = struct { error.ConfigFailed, ); + if (alpn) |protos| try check( + mbedtls_ssl_conf_alpn_protocols(config.ptr, protos), + "ssl_conf_alpn_protocols", + error.ConfigFailed, + ); + return .{ .entropy = entropy, .drbg = drbg, @@ -518,6 +536,9 @@ extern fn mbedtls_ssl_config_defaults( extern fn mbedtls_ssl_conf_rng(conf: *SslConfig, f_rng: *const RngFn, p_rng: ?*anyopaque) void; extern fn mbedtls_ssl_conf_authmode(conf: *SslConfig, authmode: c_int) void; extern fn mbedtls_ssl_conf_own_cert(conf: *SslConfig, own_cert: *X509Crt, pk_key: *PkContext) c_int; +/// Records the pointer to `protos` (NULL-terminated, decreasing preference); +/// the list must outlive the configuration. +extern fn mbedtls_ssl_conf_alpn_protocols(conf: *SslConfig, protos: [*:null]const ?[*:0]const u8) c_int; extern fn mbedtls_x509_crt_init(crt: *X509Crt) void; extern fn mbedtls_x509_crt_free(crt: *X509Crt) void; @@ -583,7 +604,7 @@ test "ServerContext.init accepts the fixture cert and key" { const fixtures = @import("test_fixtures"); const gpa = std.testing.allocator; - var ctx = try ServerContext.init(gpa, fixtures.cert_pem, fixtures.key_pem); + var ctx = try ServerContext.init(gpa, fixtures.cert_pem, fixtures.key_pem, null); defer ctx.deinit(gpa); } @@ -596,7 +617,7 @@ test "ServerContext.init rejects a truncated certificate" { try std.testing.expectError( error.CertParse, - ServerContext.init(gpa, truncated, fixtures.key_pem), + ServerContext.init(gpa, truncated, fixtures.key_pem, null), ); } @@ -609,7 +630,7 @@ test "ServerContext.init rejects a truncated private key" { try std.testing.expectError( error.KeyParse, - ServerContext.init(gpa, fixtures.cert_pem, truncated), + ServerContext.init(gpa, fixtures.cert_pem, truncated, null), ); } @@ -619,10 +640,19 @@ test "ServerContext.init rejects a key that is not the certificate's" { try std.testing.expectError( error.KeyMismatch, - ServerContext.init(gpa, fixtures.cert_pem, fixtures.mismatched_key_pem), + ServerContext.init(gpa, fixtures.cert_pem, fixtures.mismatched_key_pem, null), ); } +test "ServerContext.init accepts a NULL-terminated ALPN protocol list" { + const fixtures = @import("test_fixtures"); + const gpa = std.testing.allocator; + + const protocols: [*:null]const ?[*:0]const u8 = &.{ "http/1.1", "dot" }; + var ctx = try ServerContext.init(gpa, fixtures.cert_pem, fixtures.key_pem, protocols); + defer ctx.deinit(gpa); +} + test "the shim agrees with the alignment contexts are allocated at" { try std.testing.expect(nx_max_context_alignment() <= context_alignment); try std.testing.expect(nx_sizeof_ssl_context() > 0); @@ -652,7 +682,7 @@ test "loopback echo between the mbedtls server and std.crypto.tls.Client" { defer threaded.deinit(); const io = threaded.io(); - var ctx = try ServerContext.init(gpa, fixtures.cert_pem, fixtures.key_pem); + var ctx = try ServerContext.init(gpa, fixtures.cert_pem, fixtures.key_pem, null); defer ctx.deinit(gpa); const listen_address: Io.net.IpAddress = .{ .ip4 = .loopback(0) }; @@ -671,6 +701,40 @@ test "loopback echo between the mbedtls server and std.crypto.tls.Client" { try server_result; } +// std.crypto.tls.Client in Zig 0.16.0 cannot send the ALPN extension +// (Client.Options has no such field), so negotiation itself is untestable +// here; this proves the ruling that a client offering no ALPN still +// completes the handshake against a context advertising a list. +test "loopback echo succeeds against a context advertising ALPN" { + const build_options = @import("build_options"); + if (!build_options.integration) return error.SkipZigTest; + + const fixtures = @import("test_fixtures"); + const gpa = std.testing.allocator; + + var threaded: Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const protocols: [*:null]const ?[*:0]const u8 = &.{"http/1.1"}; + var ctx = try ServerContext.init(gpa, fixtures.cert_pem, fixtures.key_pem, protocols); + defer ctx.deinit(gpa); + + const listen_address: Io.net.IpAddress = .{ .ip4 = .loopback(0) }; + var server = try listen_address.listen(io, .{ .reuse_address = true }); + defer server.deinit(io); + + var server_task = try io.concurrent(echoOnce, .{ gpa, &ctx, io, &server }); + const client_result = runEchoClient(io, server.socket.address); + const server_result = if (client_result) |_| + server_task.await(io) + else |_| + server_task.cancel(io); + + try client_result; + try server_result; +} + test "a transport EOF without close_notify reads as a truncated stream" { const build_options = @import("build_options"); if (!build_options.integration) return error.SkipZigTest; @@ -682,7 +746,7 @@ test "a transport EOF without close_notify reads as a truncated stream" { defer threaded.deinit(); const io = threaded.io(); - var ctx = try ServerContext.init(gpa, fixtures.cert_pem, fixtures.key_pem); + var ctx = try ServerContext.init(gpa, fixtures.cert_pem, fixtures.key_pem, null); defer ctx.deinit(gpa); const listen_address: Io.net.IpAddress = .{ .ip4 = .loopback(0) }; diff --git a/src/server/cert_store.zig b/src/server/cert_store.zig new file mode 100644 index 0000000..43a556b --- /dev/null +++ b/src/server/cert_store.zig @@ -0,0 +1,810 @@ +//! Refcounted certificate holder for the DoH/DoT listeners (PLAN §9, +//! milestone-10 rulings 6-7). One `CertStore` owns the published +//! `tls_server.ServerContext` generation for one endpoint; listeners `acquire` +//! it per connection and `release` it when the connection ends, so a reload +//! never frees a context while a handshake or stream still uses it. +//! +//! Reload is publish-nothing-on-failure (PLAN:297): both PEM files are read +//! and a whole new context built before anything is swapped, and any failure +//! leaves the old generation serving. The watcher polls `statFile` every +//! `poll_interval_s` — there is no usable file-change notification inside +//! `std.Io` — and compares mtime+size of BOTH files against the pair recorded +//! at the last successful load, so a same-second atomic rename still registers +//! through the size. + +const std = @import("std"); +const tls_server = @import("../platform/tls_server.zig"); + +const log = std.log.scoped(.cert_store); + +pub const poll_interval_s = 30; + +/// Largest PEM file `reload` accepts, applied to the certificate chain and the +/// key separately. Real chains are a few KiB; anything bigger is a wrong file. +pub const max_pem_bytes = 64 * 1024; + +pub const ReloadError = error{ + OutOfMemory, + /// The certificate file could not be opened, read, or stat'ed. + CertUnreadable, + CertTooLarge, + /// The private key file could not be opened, read, or stat'ed. + KeyUnreadable, + KeyTooLarge, + /// The certificate chain PEM could not be parsed. + CertParse, + /// The private key PEM could not be parsed. + KeyParse, + /// The private key does not belong to the leaf certificate. + KeyMismatch, + /// Seeding the CTR-DRBG from the platform entropy source failed. + EntropyFailed, + /// Mbed TLS rejected the server configuration. + ConfigFailed, +}; + +/// One line for the operator, used by `POST /api/certs/reload` as the `error` +/// payload field and by the watcher's warning. +pub fn humanMessage(err: ReloadError) []const u8 { + return switch (err) { + error.OutOfMemory => "out of memory", + error.CertUnreadable => "certificate file is not readable", + error.CertTooLarge => "certificate file exceeds 64 KiB", + error.KeyUnreadable => "private key file is not readable", + error.KeyTooLarge => "private key file exceeds 64 KiB", + error.CertParse => "certificate PEM could not be parsed", + error.KeyParse => "private key PEM could not be parsed", + error.KeyMismatch => "private key does not belong to the certificate", + error.EntropyFailed => "seeding the TLS random generator failed", + error.ConfigFailed => "building the TLS server configuration failed", + }; +} + +/// The identity of one file's content as far as the watcher can see it +/// without reading: modification time and size together, because an atomic +/// rename within one filesystem timestamp granule still changes the size in +/// any realistic re-issue, and a truncate-and-rewrite changes the mtime. +pub const FileSig = struct { + mtime_ns: i96, + size: u64, +}; + +/// The stat pair recorded at the last successful load. +pub const Signature = struct { + cert: FileSig, + key: FileSig, +}; + +/// The watcher's whole decision, pure so a table can test it: reload exactly +/// when either file's mtime or size differs from the loaded pair. +pub fn changed(loaded: Signature, observed: Signature) bool { + return fileChanged(loaded.cert, observed.cert) or fileChanged(loaded.key, observed.key); +} + +fn fileChanged(loaded: FileSig, observed: FileSig) bool { + return loaded.mtime_ns != observed.mtime_ns or loaded.size != observed.size; +} + +/// One published generation. Freed only when `retired` and `refs == 0`; both +/// transitions happen under the store's mutex, so the last releaser (or the +/// reload that retires an idle entry) frees it exactly once. +pub const Entry = struct { + ctx: tls_server.ServerContext, + refs: u32, + retired: bool, +}; + +/// See `CertStore.after_load_hook`. +pub const ReloadHook = struct { + ctx: *anyopaque, + call: *const fn (ctx: *anyopaque, io: std.Io) void, +}; + +pub const CertStore = struct { + gpa: std.mem.Allocator, + /// Borrowed from the config; must outlive the store. + cert_path: []const u8, + /// Borrowed from the config; must outlive the store. + key_path: []const u8, + /// Passed through to every `ServerContext.init`; the same lifetime rule + /// applies — a comptime-constant NULL-terminated array, never memory that + /// can go away before the store. + alpn: ?[*:null]const ?[*:0]const u8, + + /// Guards `current`, `loaded`, and every `Entry.refs`/`Entry.retired`. + /// `lockUncancelable` throughout: `acquire`/`release` sit on the + /// per-connection path with no error union for `error.Canceled`, and no + /// critical section here holds I/O. + mutex: std.Io.Mutex, + /// Serializes whole reloads (read files, build context, publish) so an + /// overlapping reload cannot publish an older read after a newer one: + /// whichever reload reads the files last publishes last. Held across file + /// I/O, which is fine — `acquire`/`release` never touch it, so connection + /// latency is unchanged. Lock order: `reload_mutex` is always taken BEFORE + /// `mutex` and never while holding it. + reload_mutex: std.Io.Mutex, + current: *Entry, + loaded: Signature, + + /// Test seam: runs inside `reload` between a successful `load` and the + /// publish, i.e. inside `reload_mutex`. Lets a test occupy the window + /// where an unserialized reload could be overtaken. Must not call + /// `reload` synchronously (that would self-deadlock on `reload_mutex`). + after_load_hook: ?ReloadHook, + + reloads: std.atomic.Value(u64), + reload_failures: std.atomic.Value(u64), + /// Wall-clock second of the last successful load, including the one in + /// `init`. + last_reload_unix: std.atomic.Value(i64), + + pub const Stats = struct { + reloads: u64, + reload_failures: u64, + last_reload_unix: i64, + }; + + /// Performs the initial load. A failure here is the boot-time "bad cert" + /// case: nothing is constructed and the caller exits with + /// `humanMessage(err)` (milestone-10 ruling 11). + pub fn init( + gpa: std.mem.Allocator, + io: std.Io, + cert_path: []const u8, + key_path: []const u8, + alpn: ?[*:null]const ?[*:0]const u8, + ) ReloadError!CertStore { + const first = try load(gpa, io, cert_path, key_path, alpn); + return .{ + .gpa = gpa, + .cert_path = cert_path, + .key_path = key_path, + .alpn = alpn, + .mutex = .init, + .reload_mutex = .init, + .current = first.entry, + .loaded = first.sig, + .after_load_hook = null, + .reloads = .init(0), + .reload_failures = .init(0), + .last_reload_unix = .init(std.Io.Clock.real.now(io).toSeconds()), + }; + } + + /// Every listener must have released its entries first: the current + /// generation must be idle, and a retired generation with refs would have + /// been freed by its last release already. + pub fn deinit(self: *CertStore, io: std.Io) void { + self.mutex.lockUncancelable(io); + const current = self.current; + std.debug.assert(current.refs == 0); + self.mutex.unlock(io); + self.destroyEntry(current); + self.* = undefined; + } + + /// Pins the current generation for one connection. The returned entry + /// stays valid until the matching `release`, across any number of reloads. + pub fn acquire(self: *CertStore, io: std.Io) *Entry { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + self.current.refs += 1; + return self.current; + } + + pub fn release(self: *CertStore, io: std.Io, entry: *Entry) void { + self.mutex.lockUncancelable(io); + std.debug.assert(entry.refs > 0); + entry.refs -= 1; + const free_it = entry.retired and entry.refs == 0; + self.mutex.unlock(io); + if (free_it) self.destroyEntry(entry); + } + + /// Builds a whole new generation from the files on disk, then swaps it in + /// and retires the old one. On ANY failure the store is untouched: the old + /// generation keeps serving and the loaded signature keeps its value, so + /// the watcher retries on its next poll. + /// + /// `reload_mutex` covers the whole read-build-publish sequence: without + /// it, a reload could read the files, stall in `ServerContext.init`, and + /// publish that stale read over a generation another reload already + /// published from newer bytes. + pub fn reload(self: *CertStore, io: std.Io) ReloadError!void { + self.reload_mutex.lockUncancelable(io); + defer self.reload_mutex.unlock(io); + + const next = load(self.gpa, io, self.cert_path, self.key_path, self.alpn) catch |err| { + _ = self.reload_failures.fetchAdd(1, .monotonic); + return err; + }; + + if (self.after_load_hook) |hook| hook.call(hook.ctx, io); + + self.mutex.lockUncancelable(io); + const old = self.current; + self.current = next.entry; + self.loaded = next.sig; + old.retired = true; + const free_old = old.refs == 0; + self.mutex.unlock(io); + + if (free_old) self.destroyEntry(old); + _ = self.reloads.fetchAdd(1, .monotonic); + self.last_reload_unix.store(std.Io.Clock.real.now(io).toSeconds(), .monotonic); + } + + /// Sleep first: `init` just loaded the files this poll would compare + /// against. `.boot` so a suspended box still sees the interval elapse. + pub fn watch(self: *CertStore, io: std.Io) std.Io.Cancelable!void { + const interval: std.Io.Clock.Duration = .{ + .raw = .fromSeconds(poll_interval_s), + .clock = .boot, + }; + while (true) { + try interval.sleep(io); + self.pollOnce(io); + } + } + + /// One watcher pass. A file that cannot be stat'ed only warns — a + /// mid-rename window or a permission slip is not evidence the certificate + /// changed, and the old one keeps serving either way. A failed reload + /// warns and counts (`reload_failures`); the signature stays at the loaded + /// pair, so every subsequent poll retries until the files parse. + pub fn pollOnce(self: *CertStore, io: std.Io) void { + const cert_sig = statSig(io, self.cert_path) catch { + log.warn("stat {s} failed; keeping the loaded certificate", .{self.cert_path}); + return; + }; + const key_sig = statSig(io, self.key_path) catch { + log.warn("stat {s} failed; keeping the loaded certificate", .{self.key_path}); + return; + }; + const observed: Signature = .{ .cert = cert_sig, .key = key_sig }; + + self.mutex.lockUncancelable(io); + const loaded = self.loaded; + self.mutex.unlock(io); + if (!changed(loaded, observed)) return; + + if (self.reload(io)) { + log.info("certificate reloaded from {s}", .{self.cert_path}); + } else |err| { + log.warn("certificate reload from {s} failed ({s}); the old certificate keeps serving", .{ + self.cert_path, + humanMessage(err), + }); + } + } + + pub fn snapshotStats(self: *const CertStore) Stats { + return .{ + .reloads = self.reloads.load(.monotonic), + .reload_failures = self.reload_failures.load(.monotonic), + .last_reload_unix = self.last_reload_unix.load(.monotonic), + }; + } + + fn destroyEntry(self: *CertStore, entry: *Entry) void { + entry.ctx.deinit(self.gpa); + self.gpa.destroy(entry); + } +}; + +const Loaded = struct { + entry: *Entry, + sig: Signature, +}; + +/// Stat first, then read: a file replaced between the two makes the recorded +/// signature stale, so the watcher's next poll sees the difference and loads +/// again. The other order would record the new signature over the old bytes +/// and miss the update. +fn load( + gpa: std.mem.Allocator, + io: std.Io, + cert_path: []const u8, + key_path: []const u8, + alpn: ?[*:null]const ?[*:0]const u8, +) ReloadError!Loaded { + const cert_sig = statSig(io, cert_path) catch return error.CertUnreadable; + const key_sig = statSig(io, key_path) catch return error.KeyUnreadable; + + const cert_pem = readPem(gpa, io, cert_path) catch |err| return switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.TooLarge => error.CertTooLarge, + error.Unreadable => error.CertUnreadable, + }; + defer gpa.free(cert_pem); + + const key_pem = readPem(gpa, io, key_path) catch |err| return switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.TooLarge => error.KeyTooLarge, + error.Unreadable => error.KeyUnreadable, + }; + defer gpa.free(key_pem); + + const entry = try gpa.create(Entry); + errdefer gpa.destroy(entry); + entry.* = .{ + .ctx = try tls_server.ServerContext.init(gpa, cert_pem, key_pem, alpn), + .refs = 0, + .retired = false, + }; + + return .{ + .entry = entry, + .sig = .{ .cert = cert_sig, .key = key_sig }, + }; +} + +/// Mbed TLS wants PEM with a terminating zero byte counted in the length, so +/// the file lands in a sentinel-terminated allocation. The limit admits +/// exactly `max_pem_bytes` and rejects the first byte beyond it. +fn readPem( + gpa: std.mem.Allocator, + io: std.Io, + path: []const u8, +) error{ OutOfMemory, TooLarge, Unreadable }![:0]u8 { + return std.Io.Dir.cwd().readFileAllocOptions( + io, + path, + gpa, + .limited(max_pem_bytes + 1), + .of(u8), + 0, + ) catch |err| switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.StreamTooLong => error.TooLarge, + else => error.Unreadable, + }; +} + +fn statSig(io: std.Io, path: []const u8) !FileSig { + const st = try std.Io.Dir.cwd().statFile(io, path, .{}); + return .{ .mtime_ns = st.mtime.nanoseconds, .size = st.size }; +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const fixtures = @import("test_fixtures"); +const testing = std.testing; + +test "the stat-compare decision table" { + const a: FileSig = .{ .mtime_ns = 1_000, .size = 100 }; + const a_later: FileSig = .{ .mtime_ns = 2_000, .size = 100 }; + const a_grown: FileSig = .{ .mtime_ns = 1_000, .size = 101 }; + const b: FileSig = .{ .mtime_ns = 5_000, .size = 500 }; + const b_later: FileSig = .{ .mtime_ns = 6_000, .size = 500 }; + const b_grown: FileSig = .{ .mtime_ns = 5_000, .size = 501 }; + + const cases = [_]struct { + loaded: Signature, + observed: Signature, + want: bool, + }{ + .{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = a, .key = b }, .want = false }, + .{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = a_later, .key = b }, .want = true }, + .{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = a_grown, .key = b }, .want = true }, + .{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = a, .key = b_later }, .want = true }, + .{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = a, .key = b_grown }, .want = true }, + .{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = a_later, .key = b_grown }, .want = true }, + // A same-second rewrite registers through the size alone. + .{ + .loaded = .{ .cert = a, .key = b }, + .observed = .{ .cert = .{ .mtime_ns = 1_000, .size = 99 }, .key = b }, + .want = true, + }, + // The pair swapped between the two files is a change, not a wash. + .{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = b, .key = a }, .want = true }, + }; + + for (cases) |case| { + try testing.expectEqual(case.want, changed(case.loaded, case.observed)); + } +} + +test "every reload error carries a human message" { + inline for (comptime @typeInfo(ReloadError).error_set.?) |e| { + try testing.expect(humanMessage(@field(anyerror, e.name)).len > 0); + } +} + +/// Fixture PEMs written into a tmp directory, addressed by cwd-relative paths +/// the same way `app.zig` hands config paths to the store. Must not move after +/// `init`: the path slices point into the buffers below. +const TestEnv = struct { + threaded: std.Io.Threaded, + tmp: testing.TmpDir, + cert_path_buf: [128]u8, + key_path_buf: [128]u8, + cert_path: []const u8, + key_path: []const u8, + + fn init(env: *TestEnv) !void { + env.threaded = .init(testing.allocator, .{}); + errdefer env.threaded.deinit(); + env.tmp = testing.tmpDir(.{}); + errdefer env.tmp.cleanup(); + + const env_io = env.threaded.io(); + try env.tmp.dir.writeFile(env_io, .{ .sub_path = "cert.pem", .data = fixtures.cert_pem }); + try env.tmp.dir.writeFile(env_io, .{ .sub_path = "key.pem", .data = fixtures.key_pem }); + env.cert_path = try std.fmt.bufPrint(&env.cert_path_buf, ".zig-cache/tmp/{s}/cert.pem", .{env.tmp.sub_path}); + env.key_path = try std.fmt.bufPrint(&env.key_path_buf, ".zig-cache/tmp/{s}/key.pem", .{env.tmp.sub_path}); + } + + fn deinit(env: *TestEnv) void { + env.tmp.cleanup(); + env.threaded.deinit(); + } + + fn io(env: *TestEnv) std.Io { + return env.threaded.io(); + } +}; + +test "loaded PEM bytes are NUL-terminated and intact" { + var env: TestEnv = undefined; + try env.init(); + defer env.deinit(); + + const pem = try readPem(testing.allocator, env.io(), env.cert_path); + defer testing.allocator.free(pem); + + try testing.expectEqualStrings(fixtures.cert_pem, pem); + try testing.expectEqual(@as(u8, 0), pem[pem.len]); +} + +test "the size cap admits 64 KiB and rejects one byte more" { + var env: TestEnv = undefined; + try env.init(); + defer env.deinit(); + const io = env.io(); + + const at_cap = try testing.allocator.alloc(u8, max_pem_bytes); + defer testing.allocator.free(at_cap); + @memset(at_cap, 'a'); + try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = at_cap }); + + const loaded = try readPem(testing.allocator, io, env.cert_path); + testing.allocator.free(loaded); + + const over = try testing.allocator.alloc(u8, max_pem_bytes + 1); + defer testing.allocator.free(over); + @memset(over, 'a'); + try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = over }); + + try testing.expectError(error.TooLarge, readPem(testing.allocator, io, env.cert_path)); +} + +test "init fails typed on a missing file and on garbage PEM" { + var env: TestEnv = undefined; + try env.init(); + defer env.deinit(); + const io = env.io(); + + try testing.expectError( + error.CertUnreadable, + CertStore.init(testing.allocator, io, "./nxdns-no-such-cert-9b31.pem", env.key_path, null), + ); + try testing.expectError( + error.KeyUnreadable, + CertStore.init(testing.allocator, io, env.cert_path, "./nxdns-no-such-key-9b31.pem", null), + ); + + try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = "not a certificate" }); + try testing.expectError( + error.CertParse, + CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null), + ); +} + +test "init fails typed on an oversized certificate" { + var env: TestEnv = undefined; + try env.init(); + defer env.deinit(); + const io = env.io(); + + const over = try testing.allocator.alloc(u8, max_pem_bytes + 1); + defer testing.allocator.free(over); + @memset(over, 'a'); + try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = over }); + + try testing.expectError( + error.CertTooLarge, + CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null), + ); +} + +test "acquire and release across a reload free the old entry after the last release" { + var env: TestEnv = undefined; + try env.init(); + defer env.deinit(); + const io = env.io(); + + var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); + defer store.deinit(io); + + const old = store.acquire(io); + const old_again = store.acquire(io); + try testing.expectEqual(old, old_again); + try testing.expectEqual(@as(u32, 2), old.refs); + try testing.expect(!old.retired); + + try store.reload(io); + + // The old generation is retired but pinned; the store publishes a new one. + try testing.expect(old.retired); + try testing.expectEqual(@as(u32, 2), old.refs); + const fresh = store.acquire(io); + try testing.expect(fresh != old); + try testing.expect(!fresh.retired); + + // The first release leaves the old entry alive for the second holder; + // the testing allocator proves the second release frees it exactly once. + store.release(io, old); + try testing.expectEqual(@as(u32, 1), old.refs); + store.release(io, old); + + store.release(io, fresh); + try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads); +} + +test "a reload with no holders frees the old entry immediately" { + var env: TestEnv = undefined; + try env.init(); + defer env.deinit(); + const io = env.io(); + + var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); + defer store.deinit(io); + + // No acquire in flight: the swap itself must free the old generation, or + // the testing allocator reports the leak at the end of this test. + try store.reload(io); + try store.reload(io); + try testing.expectEqual(@as(u64, 2), store.snapshotStats().reloads); +} + +test "a failed reload publishes nothing" { + var env: TestEnv = undefined; + try env.init(); + defer env.deinit(); + const io = env.io(); + + var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); + defer store.deinit(io); + + const before = store.acquire(io); + store.release(io, before); + + // Bad PEM bytes: the file reads fine and fails in the parser. + try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = "garbage" }); + try testing.expectError(error.CertParse, store.reload(io)); + + var held = store.acquire(io); + try testing.expectEqual(before, held); + try testing.expect(!held.retired); + store.release(io, held); + + // Bad path: the key file vanishes. + try env.tmp.dir.deleteFile(io, "key.pem"); + try testing.expectError(error.KeyUnreadable, store.reload(io)); + + held = store.acquire(io); + try testing.expectEqual(before, held); + store.release(io, held); + + const stats = store.snapshotStats(); + try testing.expectEqual(@as(u64, 0), stats.reloads); + try testing.expectEqual(@as(u64, 2), stats.reload_failures); +} + +test "a mismatched key pair fails the reload and keeps the old pair" { + var env: TestEnv = undefined; + try env.init(); + defer env.deinit(); + const io = env.io(); + + var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); + defer store.deinit(io); + + const before = store.acquire(io); + store.release(io, before); + + try env.tmp.dir.writeFile(io, .{ .sub_path = "key.pem", .data = fixtures.mismatched_key_pem }); + try testing.expectError(error.KeyMismatch, store.reload(io)); + + const held = store.acquire(io); + try testing.expectEqual(before, held); + store.release(io, held); + try testing.expectEqual(@as(u64, 1), store.snapshotStats().reload_failures); +} + +test "pollOnce reloads on a changed stat pair and stays put on an unchanged one" { + var env: TestEnv = undefined; + try env.init(); + defer env.deinit(); + const io = env.io(); + + var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); + defer store.deinit(io); + + store.pollOnce(io); + try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads); + + // Same certificate plus a trailing newline: the PEM still parses and the + // size differs even when the rewrite lands within one timestamp granule. + const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" }); + defer testing.allocator.free(grown); + try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = grown }); + + const old = store.acquire(io); + store.release(io, old); + store.pollOnce(io); + + try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads); + const fresh = store.acquire(io); + try testing.expect(fresh != old); + store.release(io, fresh); + + store.pollOnce(io); + try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads); +} + +test "pollOnce warns and keeps serving when a reload fails" { + var env: TestEnv = undefined; + try env.init(); + defer env.deinit(); + const io = env.io(); + + var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); + defer store.deinit(io); + + const before = store.acquire(io); + store.release(io, before); + + try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = "still not a certificate" }); + store.pollOnce(io); + + const stats = store.snapshotStats(); + try testing.expectEqual(@as(u64, 0), stats.reloads); + try testing.expectEqual(@as(u64, 1), stats.reload_failures); + + const held = store.acquire(io); + try testing.expectEqual(before, held); + store.release(io, held); +} + +test "pollOnce does nothing when a file cannot be stat'ed" { + var env: TestEnv = undefined; + try env.init(); + defer env.deinit(); + const io = env.io(); + + var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); + defer store.deinit(io); + + try env.tmp.dir.deleteFile(io, "cert.pem"); + store.pollOnce(io); + + const stats = store.snapshotStats(); + try testing.expectEqual(@as(u64, 0), stats.reloads); + try testing.expectEqual(@as(u64, 0), stats.reload_failures); +} + +test "init records the load time and reload advances it" { + var env: TestEnv = undefined; + try env.init(); + defer env.deinit(); + const io = env.io(); + + var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); + defer store.deinit(io); + + const at_init = store.snapshotStats(); + try testing.expectEqual(@as(u64, 0), at_init.reloads); + try testing.expectEqual(@as(u64, 0), at_init.reload_failures); + try testing.expect(at_init.last_reload_unix > 0); + + try store.reload(io); + const after = store.snapshotStats(); + try testing.expectEqual(@as(u64, 1), after.reloads); + try testing.expect(after.last_reload_unix >= at_init.last_reload_unix); +} + +test "init accepts a comptime ALPN list" { + var env: TestEnv = undefined; + try env.init(); + defer env.deinit(); + const io = env.io(); + + const alpn: [1:null]?[*:0]const u8 = .{"http/1.1"}; + var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, &alpn); + defer store.deinit(io); + + try store.reload(io); + try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads); +} + +test "the watch loop sleeps before polling and returns on cancel" { + var env: TestEnv = undefined; + try env.init(); + defer env.deinit(); + const io = env.io(); + + var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); + defer store.deinit(io); + + var future = try io.concurrent(CertStore.watch, .{ &store, io }); + // Cancelling immediately proves the loop starts by sleeping rather than + // by reloading. + try testing.expectError(error.Canceled, future.cancel(io)); + try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads); +} + +// Pins the reload-ordering race via `after_load_hook`: the hook runs inside +// reload A's load-to-publish window, where it (1) probes that `reload_mutex` +// is held, the serialization this exists for (without it a second reload +// could publish here and be overwritten by A's stale read), then (2) stages +// newer cert bytes and starts an overlapping reload B. B can only read the +// files after A publishes, so B both reads and publishes last and the newer +// bytes win. This proves the lock spans the window and last-read-wins for +// this schedule; it does not enumerate every interleaving. +test "a reload overlapping another reload's window publishes last" { + var env: TestEnv = undefined; + try env.init(); + defer env.deinit(); + const io = env.io(); + + var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); + defer store.deinit(io); + + const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" }); + defer testing.allocator.free(grown); + + const Window = struct { + store: *CertStore, + env: *TestEnv, + grown: []const u8, + overlapping: ?std.Io.Future(ReloadError!void) = null, + lock_spans_window: bool = false, + staged: bool = false, + + // Reload B re-enters this hook under `reload_mutex`, so its read of + // `overlapping` is ordered after A's write and the early return makes + // the window work run exactly once. + fn call(ctx: *anyopaque, hook_io: std.Io) void { + const w: *@This() = @ptrCast(@alignCast(ctx)); + if (w.overlapping != null) return; + if (w.store.reload_mutex.tryLock()) { + w.store.reload_mutex.unlock(hook_io); + } else { + w.lock_spans_window = true; + } + w.env.tmp.dir.writeFile(hook_io, .{ .sub_path = "cert.pem", .data = w.grown }) catch return; + w.staged = true; + w.overlapping = hook_io.concurrent(CertStore.reload, .{ w.store, hook_io }) catch null; + } + }; + + var window: Window = .{ .store = &store, .env = &env, .grown = grown }; + store.after_load_hook = .{ .ctx = &window, .call = Window.call }; + + try store.reload(io); + + try testing.expect(window.lock_spans_window); + try testing.expect(window.staged); + var overlapping = window.overlapping orelse return error.ConcurrentUnavailable; + try overlapping.await(io); + + try testing.expectEqual(@as(u64, 2), store.snapshotStats().reloads); + store.mutex.lockUncancelable(io); + const final = store.loaded; + store.mutex.unlock(io); + try testing.expectEqual(@as(u64, grown.len), final.cert.size); +} diff --git a/src/server/doh_server.zig b/src/server/doh_server.zig new file mode 100644 index 0000000..6b4753a --- /dev/null +++ b/src/server/doh_server.zig @@ -0,0 +1,1478 @@ +//! The DoH listener (RFC 8484 over HTTP/1.1 + TLS, milestone-10 ruling 2). +//! +//! The shape is web/server.zig's: one `std.http.Server` per connection over our +//! own accept loop, fixed pre-allocated connection slots, a keep-alive loop per +//! connection that ends on `error.HttpConnectionClosing`, and the same shutdown +//! split — `deinit` shuts live connections down and drains, a canceled `serve` +//! cancels the connection group because HTTP keep-alive has no deadline of its +//! own. The difference is the transport: after the TCP accept, a certificate +//! generation is pinned (`CertStore.acquire`) and `ServerStream.accept` runs the +//! TLS handshake, and `std.http.Server` sits on the stream's plaintext +//! reader/writer (http/Server.zig:25 takes arbitrary interfaces). +//! +//! The handshake runs under the same race budget tcp_server applies to its +//! reads (ruling 3's rationale): a client that connects and never handshakes +//! must not pin one of the 64 slots forever. +//! +//! Over capacity the raw TCP stream is closed and counted, with no response. +//! The web listener's 503 is not possible here: HTTP exists only on the far +//! side of a TLS handshake, and a handshake costs a slot's buffers and a +//! certificate acquisition — exactly what an over-capacity listener does not +//! have to spend. Closing is the cheapest honest refusal. +//! +//! Only `/dns-query` exists. This is not the admin API, so errors are plain +//! text, not JSON, and there are no cache-control headers (LAN, no +//! intermediaries). + +const std = @import("std"); +const http = std.http; +const net = std.Io.net; +const Allocator = std.mem.Allocator; + +const address = @import("../platform/address.zig"); +const cert_store = @import("cert_store.zig"); +const doh_client = @import("../upstream/doh_client.zig"); +const handler = @import("handler.zig"); +const model = @import("../config/model.zig"); +const tls_server = @import("../platform/tls_server.zig"); +const transport = @import("../upstream/transport.zig"); + +const log = std.log.scoped(.doh_server); + +pub const dns_query_path = "/dns-query"; + +/// Ruling 5. Mbed TLS records the pointer, so the list must outlive every +/// `ServerContext` built with it; a module-scope comptime constant has static +/// lifetime. The composition root passes this to the DoH endpoint's +/// `CertStore.init`. +pub const alpn_protocols: [*:null]const ?[*:0]const u8 = &.{"http/1.1"}; + +/// The `ServerStream` plaintext receive buffer, which `std.http.Server` also +/// uses as the request head, so this is the maximum head size +/// (http/Server.zig:32 sets `max_head_len` from it). +const recv_buffer_len = 8 * 1024; +const send_buffer_len = 4 * 1024; + +pub const default_max_connections: u16 = 64; + +/// How long the accept loop waits after an unexpected accept failure, so a +/// persistent one cannot turn the loop into a spin. +const retry_delay: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake }; + +const allow_header: http.Header = .{ .name = "allow", .value = "GET, POST" }; + +pub const Options = struct { + max_connections: u16 = default_max_connections, + /// The TLS handshake budget. Requests themselves have no timeout, exactly + /// like the web listener: the port is LAN-facing and the cancel path is + /// what bounds shutdown. Only the handshake — which happens before the + /// connection has proven it speaks anything at all — is raced. + idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake }, +}; + +pub const Stats = struct { + connections: std.atomic.Value(u64) = .init(0), + rejected_at_capacity: std.atomic.Value(u64) = .init(0), + rejected_at_shutdown: std.atomic.Value(u64) = .init(0), + accept_errors: std.atomic.Value(u64) = .init(0), + tls_handshake_failures: std.atomic.Value(u64) = .init(0), + idle_timeouts: std.atomic.Value(u64) = .init(0), + connection_errors: std.atomic.Value(u64) = .init(0), + /// Every 4xx answered on `/dns-query` and every miss beside it: the + /// visibility counter for clients that speak, but speak wrongly. + bad_requests: std.atomic.Value(u64) = .init(0), +}; + +pub const Snapshot = struct { + connections: u64, + rejected_at_capacity: u64, + rejected_at_shutdown: u64, + accept_errors: u64, + tls_handshake_failures: u64, + idle_timeouts: u64, + connection_errors: u64, + bad_requests: u64, +}; + +/// Lifecycle of the accept loop, mirroring tcp_server: `serve` claims +/// `.serving`, `deinit` publishes `.closing`, and the two meet at `stopped`. +const State = enum(u32) { idle, serving, closing }; + +/// `.closing` exists so `deinit` never shuts down a descriptor its own task is +/// about to close. +const ConnState = enum { free, active, closing }; + +/// Why the accept loop stopped, which decides what happens to the connections +/// still in flight. +const Stop = enum { closing, canceled }; + +const Claim = union(enum) { + slot: usize, + at_capacity, + shutting_down, +}; + +pub const DohServer = struct { + /// Allocates the per-connection Mbed TLS context in `ServerStream.accept`. + gpa: Allocator, + handler: *handler.Handler, + certs: *cert_store.CertStore, + listener: net.Server, + conns: []Conn, + mutex: std.Io.Mutex, + /// Guarded by `mutex`, set in the same critical section that shuts the live + /// connections down. + shutdown_begun: bool, + options: Options, + stats: Stats, + run_state: std.atomic.Value(State), + stopped: std.Io.Event, + + /// One slot is ~150 KiB, so the default 64 connections cost ~9.4 MiB. The + /// two message buffers cannot shrink: a POST body and the reply both go up + /// to the 65535 bytes a DNS message can be. + pub const Conn = struct { + /// `ServerStream` plaintext buffers; `read_buf` doubles as the HTTP + /// head cap (see `recv_buffer_len`). + read_buf: [recv_buffer_len]u8, + write_buf: [send_buffer_len]u8, + /// The decoded query: a POST body or a GET `dns` parameter. + query: [transport.max_message_len]u8, + reply: [transport.max_message_len]u8, + /// The handler's per-query working memory. A connection is answered + /// serially, so one query uses it at a time. + scratch: handler.Scratch, + /// Valid between a successful `ServerStream.accept` and the + /// `close(gpa)` in `serveConn`'s defer. + tls: tls_server.ServerStream, + stream: net.Stream, + peer: net.IpAddress, + /// Guarded by `DohServer.mutex`. + conn_state: ConnState, + }; + + pub const ListenError = net.IpAddress.ListenError || error{OutOfMemory}; + + pub fn listen( + gpa: Allocator, + io: std.Io, + listen_address: net.IpAddress, + h: *handler.Handler, + certs: *cert_store.CertStore, + options: Options, + ) ListenError!DohServer { + std.debug.assert(options.max_connections > 0); + + const conns = try gpa.alloc(Conn, options.max_connections); + errdefer gpa.free(conns); + for (conns) |*conn| conn.conn_state = .free; + + const listener = try listen_address.listen(io, .{ .reuse_address = true }); + + return .{ + .gpa = gpa, + .handler = h, + .certs = certs, + .listener = listener, + .conns = conns, + .mutex = .init, + .shutdown_begun = false, + .options = options, + .stats = .{}, + .run_state = .init(.idle), + .stopped = .unset, + }; + } + + /// The kernel-assigned address. A port of 0 in `listen` resolves here. + pub fn boundAddress(self: *const DohServer) net.IpAddress { + return self.listener.socket.address; + } + + pub fn snapshotStats(self: *const DohServer) Snapshot { + return .{ + .connections = self.stats.connections.load(.monotonic), + .rejected_at_capacity = self.stats.rejected_at_capacity.load(.monotonic), + .rejected_at_shutdown = self.stats.rejected_at_shutdown.load(.monotonic), + .accept_errors = self.stats.accept_errors.load(.monotonic), + .tls_handshake_failures = self.stats.tls_handshake_failures.load(.monotonic), + .idle_timeouts = self.stats.idle_timeouts.load(.monotonic), + .connection_errors = self.stats.connection_errors.load(.monotonic), + .bad_requests = self.stats.bad_requests.load(.monotonic), + }; + } + + /// Accept loop. Returns when the task is canceled or `deinit` stops it. + pub fn serve(self: *DohServer, io: std.Io) void { + if (self.run_state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return; + + var group: std.Io.Group = .init; + switch (self.acceptLoop(io, &group)) { + // `deinit` shut every live connection down before it published + // `.closing`, so each one is unblocked and finishing on its own. + // Awaiting them means a half-written response still goes out whole. + .closing => { + const prev = io.swapCancelProtection(.blocked); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; + _ = io.swapCancelProtection(prev); + }, + // Nothing has shut these connections down, and an idle keep-alive + // connection has no deadline of its own, so draining could wait + // forever. Cancel joins, so the slots are quiet by the time `serve` + // returns; the price is the one response that was mid-write. + .canceled => group.cancel(io), + } + + self.stopped.set(io); + } + + pub fn deinit(self: *DohServer, gpa: Allocator, io: std.Io) void { + const was_serving = self.run_state.swap(.closing, .acq_rel) == .serving; + + // Shutting the listening socket down is the documented way to unblock a + // pending `accept`: it fails with `error.SocketNotListening`. + const listener: net.Stream = .{ .socket = self.listener.socket }; + listener.shutdown(io, .both) catch |err| { + log.debug("doh listener shutdown failed: {t}", .{err}); + }; + + self.beginShutdown(io); + + if (was_serving) self.stopped.waitUncancelable(io); + + self.listener.deinit(io); + gpa.free(self.conns); + self.* = undefined; + } + + fn acceptLoop(self: *DohServer, io: std.Io, group: *std.Io.Group) Stop { + while (self.run_state.load(.acquire) == .serving) { + const stream = self.listener.accept(io) catch |err| switch (err) { + error.Canceled => return .canceled, + error.SocketNotListening => return .closing, + else => { + bump(&self.stats.accept_errors); + log.debug("doh accept failed: {t}", .{err}); + retry_delay.sleep(io) catch return .canceled; + continue; + }, + }; + + const index = switch (self.claim(io, stream)) { + .slot => |index| index, + // See the module comment: no 503 without a handshake, so over + // capacity the stream is closed raw and the refusal counted. + .at_capacity => { + bump(&self.stats.rejected_at_capacity); + stream.close(io); + continue; + }, + .shutting_down => { + bump(&self.stats.rejected_at_shutdown); + stream.close(io); + return .closing; + }, + }; + + group.concurrent(io, serveConn, .{ self, io, index }) catch |err| switch (err) { + error.ConcurrencyUnavailable => { + bump(&self.stats.rejected_at_capacity); + self.finish(io, index); + continue; + }, + }; + + bump(&self.stats.connections); + } + + // The loop condition failed, which only `deinit` can cause. + return .closing; + } + + fn serveConn(self: *DohServer, io: std.Io, index: usize) void { + defer self.finish(io, index); + + const conn = &self.conns[index]; + + // Pinned for the whole connection (ruling 6): a reload never frees the + // generation this stream handshook against. + const entry = self.certs.acquire(io); + defer self.certs.release(io, entry); + + var handshook = false; + switch (race(io, self.options.idle_timeout, handshake, .{ self.gpa, conn, &entry.ctx, io, &handshook })) { + .ok => {}, + // The select can report the expiry or the cancellation after the + // handshake has in fact succeeded. The flag is written before the + // race joins its tasks, so a TLS context that exists is closed on + // every path, exactly once. + .timed_out => { + if (handshook) conn.tls.close(self.gpa); + bump(&self.stats.idle_timeouts); + return; + }, + .canceled => { + if (handshook) conn.tls.close(self.gpa); + return; + }, + .failed => { + if (handshook) conn.tls.close(self.gpa); + bump(&self.stats.tls_handshake_failures); + return; + }, + } + // Flushes, sends close_notify and frees the TLS context on every exit + // path below; `finish` closes the TCP stream afterwards. + defer conn.tls.close(self.gpa); + + var connection: http.Server = .init(conn.tls.reader(), conn.tls.writer()); + + while (connection.reader.state == .ready) { + var request = connection.receiveHead() catch |err| switch (err) { + // The normal end of a keep-alive connection. + error.HttpConnectionClosing => return, + // Cancellation and a vanished client both land here; neither is + // worth a counter. + error.ReadFailed => return, + error.HttpHeadersOversize, + error.HttpRequestTruncated, + error.HttpHeadersInvalid, + => { + bump(&self.stats.connection_errors); + return; + }, + }; + + // RFC 9110 §8.6: a request with neither content-length nor + // transfer-encoding has an empty body, but std leaves the head + // saying "unknown" and `discardBody` asserts on it inside every + // `respond` (http/Server.zig:631). A zero length is what the head + // means, and `bodyReader` goes straight to `.ready` on it. + if (request.head.method.requestHasBody() and + request.head.transfer_encoding == .none and + request.head.content_length == null) + { + request.head.content_length = 0; + } + + const next = self.handleRequest(io, conn, &request) catch |err| switch (err) { + // The peer went away mid-response. Normal. + error.WriteFailed => return, + error.HttpExpectationFailed, error.ReadFailed => { + bump(&self.stats.connection_errors); + return; + }, + }; + // The loop condition alone cannot end these connections: after a + // fully-read POST body the stdlib reader is `.ready` again even + // when the response said `connection: close`, so a client that + // ignores the header could keep the slot. The server hangs up. + if (next == .close) return; + } + } + + const HandleError = error{ WriteFailed, HttpExpectationFailed, ReadFailed }; + + /// What `serveConn`'s keep-alive loop does after the response went out. + const Next = enum { keep_open, close }; + + fn handleRequest( + self: *DohServer, + io: std.Io, + conn: *Conn, + request: *http.Server.Request, + ) HandleError!Next { + // Request smuggling guard: for methods where `requestHasBody` is + // false, `respond` (http/Server.zig:618) never drains body bytes, so + // content-length or chunked framing on e.g. a GET would leave those + // bytes in the stream to be parsed as the next request head. Routing + // still decides the status (a framed DELETE is a 405, a framed GET + // beside `/dns-query` a 404); the guard only forces the connection + // closed, which discards the framed bytes without reading them. + const frames_unreadable = framesUnreadableBody( + request.head.method, + request.head.transfer_encoding, + request.head.content_length, + ); + // Any refusal while framed body bytes are still unread must also + // close, whatever the method: with keep_alive=true `respond` drains + // the ENTIRE body before sending a byte (http/Server.zig:618 keeps the + // connection only after `discardRemaining`), so a chunked body that + // never terminates would pin the slot with no response out. + // keep_alive=false skips the drain; the routed status still goes out + // first, then the connection ends. + const keep = !framesBody(request.head.transfer_encoding, request.head.content_length); + + const target = request.head.target; + const split = std.mem.findScalar(u8, target, '?') orelse target.len; + if (!std.mem.eql(u8, target[0..split], dns_query_path)) { + return self.refuse(request, .not_found, "not found\n", &.{}, keep); + } + + switch (request.head.method) { + .GET => { + // The routed method itself must not frame a body; refused + // before the query string is even looked at. + if (frames_unreadable) { + return self.refuse(request, .bad_request, "bad request\n", &.{}, false); + } + const raw_query = if (split == target.len) "" else target[split + 1 ..]; + const value = switch (dnsParam(raw_query)) { + .value => |v| v, + .missing, .duplicate => { + return self.refuse(request, .bad_request, "bad request\n", &.{}, true); + }, + }; + const query = decodeDnsValue(value, &conn.query) catch { + return self.refuse(request, .bad_request, "bad request\n", &.{}, true); + }; + return self.answer(io, conn, request, query); + }, + .POST => { + if (!doh_client.contentTypeOk(request.head.content_type)) { + return self.refuse(request, .unsupported_media_type, "unsupported media type\n", &.{}, keep); + } + // Refused before any body byte is read; keep_alive=false so the + // oversize body is never drained, the connection just ends. + if (request.head.content_length) |len| if (len > transport.max_message_len) { + return self.refuse(request, .payload_too_large, "payload too large\n", &.{}, false); + }; + const reader = try request.readerExpectContinue(&.{}); + const got = reader.readSliceShort(&conn.query) catch return error.ReadFailed; + // A full buffer is either a message of exactly the DNS maximum + // or a chunked body that keeps going; one probe byte decides. + if (got == conn.query.len) { + var probe: [1]u8 = undefined; + const extra = reader.readSliceShort(&probe) catch return error.ReadFailed; + if (extra != 0) { + return self.refuse(request, .payload_too_large, "payload too large\n", &.{}, false); + } + } + return self.answer(io, conn, request, conn.query[0..got]); + }, + else => return self.refuse(request, .method_not_allowed, "method not allowed\n", &.{allow_header}, keep), + } + } + + /// Ruling 2: `.drop` means "no answer on purpose" — malformed or + /// refused-silent, which includes the empty body — and maps to 400 with + /// the connection closed, GET and POST alike. + fn answer( + self: *DohServer, + io: std.Io, + conn: *Conn, + request: *http.Server.Request, + query: []const u8, + ) error{ WriteFailed, HttpExpectationFailed }!Next { + const outcome = self.handler.handle( + io, + .tcp, + address.NetAddress.fromIp(conn.peer), + query, + &conn.reply, + &conn.scratch, + ); + switch (outcome) { + .drop => return self.refuse(request, .bad_request, "bad request\n", &.{}, false), + .reply => |bytes| { + try request.respond(bytes, .{ + .extra_headers = &.{.{ .name = "content-type", .value = doh_client.media_type }}, + }); + // `respond` honors the request head's `connection: close` + // (http/Server.zig:628), so the loop must agree with the wire. + return if (request.head.keep_alive) .keep_open else .close; + }, + } + } + + fn refuse( + self: *DohServer, + request: *http.Server.Request, + status: http.Status, + body: []const u8, + extra_headers: []const http.Header, + keep_alive: bool, + ) error{ WriteFailed, HttpExpectationFailed }!Next { + bump(&self.stats.bad_requests); + try request.respond(body, .{ + .status = status, + .keep_alive = keep_alive, + .extra_headers = extra_headers, + }); + // `respond` ANDs `keep_alive` with the request head's own + // (http/Server.zig:628); a client that asked to close gets + // `connection: close` either way, and the loop must agree. + return if (keep_alive and request.head.keep_alive) .keep_open else .close; + } + + fn claim(self: *DohServer, io: std.Io, stream: net.Stream) Claim { + // Uncancelable: this section takes no Io and never blocks on a peer, so + // losing the lock mid-update would leak a slot for nothing. + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + const outcome = decideClaim(self.conns, self.shutdown_begun); + switch (outcome) { + .slot => |index| { + self.conns[index].stream = stream; + self.conns[index].peer = stream.socket.address; + self.conns[index].conn_state = .active; + }, + .at_capacity, .shutting_down => {}, + } + return outcome; + } + + fn finish(self: *DohServer, io: std.Io, index: usize) void { + const conn = &self.conns[index]; + + self.mutex.lockUncancelable(io); + conn.conn_state = .closing; + self.mutex.unlock(io); + + // The socket is released even when this task is being torn down: the + // next cancelable call would otherwise skip the close. + const prev = io.swapCancelProtection(.blocked); + conn.stream.close(io); + _ = io.swapCancelProtection(prev); + + self.mutex.lockUncancelable(io); + conn.conn_state = .free; + self.mutex.unlock(io); + } + + /// Closes the door on new connections and unblocks the live ones under one + /// hold of the mutex, so no `claim` can slip between the two. + fn beginShutdown(self: *DohServer, io: std.Io) void { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + self.shutdown_begun = true; + + for (self.conns) |*conn| { + if (conn.conn_state != .active) continue; + conn.stream.shutdown(io, .both) catch |err| { + log.debug("doh connection shutdown failed: {t}", .{err}); + }; + } + } +}; + +/// `handshook` is set only after `accept` returned, so `serveConn` knows on +/// every race outcome whether `conn.tls` holds a context that must be closed. +fn handshake( + gpa: Allocator, + conn: *DohServer.Conn, + ctx: *tls_server.ServerContext, + io: std.Io, + handshook: *bool, +) anyerror!void { + try conn.tls.accept(gpa, ctx, io, &conn.stream, &conn.read_buf, &conn.write_buf); + handshook.* = true; +} + +/// True when the head frames body bytes on the wire. A bare +/// `content-length: 0` frames nothing. +fn framesBody(transfer_encoding: http.TransferEncoding, content_length: ?u64) bool { + return transfer_encoding != .none or (content_length orelse 0) != 0; +} + +/// True when the request frames a body the server would never read: the +/// stdlib drains bodies only for methods where `requestHasBody` is true, so +/// content-length > 0 or any transfer-encoding on the other methods would +/// smuggle those bytes into the next request head. +fn framesUnreadableBody( + method: http.Method, + transfer_encoding: http.TransferEncoding, + content_length: ?u64, +) bool { + if (method.requestHasBody()) return false; + return framesBody(transfer_encoding, content_length); +} + +const DnsParam = union(enum) { + missing, + /// Two `dns` parameters answer differently depending on which one is read; + /// a validating decoder refuses to pick. + duplicate, + value: []const u8, +}; + +/// Finds the `dns` parameter in a raw query string. No percent-decoding +/// happens anywhere in this parser: RFC 8484 §4.1 defines the value as +/// base64url, whose alphabet contains nothing that needs escaping, so a `%` is +/// simply an invalid character for the decoder to reject. +fn dnsParam(raw_query: []const u8) DnsParam { + var found: ?[]const u8 = null; + var it = std.mem.splitScalar(u8, raw_query, '&'); + while (it.next()) |pair| { + if (!std.mem.startsWith(u8, pair, "dns=")) continue; + if (found != null) return .duplicate; + found = pair["dns=".len..]; + } + return if (found) |value| .{ .value = value } else .missing; +} + +/// RFC 8484 §6: base64url without padding. Padding, whitespace, `%` and every +/// other character outside the alphabet are rejected, not skipped — a lenient +/// decode would make distinct request strings alias one query. +fn decodeDnsValue(value: []const u8, dest: []u8) error{Invalid}![]u8 { + if (value.len == 0) return error.Invalid; + const decoder = std.base64.url_safe_no_pad.Decoder; + const len = decoder.calcSizeForSlice(value) catch return error.Invalid; + if (len > dest.len) return error.Invalid; + decoder.decode(dest[0..len], value) catch return error.Invalid; + return dest[0..len]; +} + +/// The whole claim rule, without the mutex, so it is testable without a backend. +fn decideClaim(conns: []const DohServer.Conn, shutdown_begun: bool) Claim { + if (shutdown_begun) return .shutting_down; + for (conns, 0..) |*conn, index| { + if (conn.conn_state == .free) return .{ .slot = index }; + } + return .at_capacity; +} + +const Outcome = union(enum) { + op: anyerror!void, + expiry: std.Io.Cancelable!void, +}; + +const RaceResult = enum { ok, timed_out, failed, canceled }; + +/// Runs one connection operation against the budget and cancels the loser +/// (tcp_server's arrangement: no stream operation in 0.16.0 takes a timeout). +fn race( + io: std.Io, + budget: std.Io.Clock.Duration, + comptime f: anytype, + args: std.meta.ArgsTuple(@TypeOf(f)), +) RaceResult { + var outcomes: [2]Outcome = undefined; + var select: std.Io.Select(Outcome) = .init(io, &outcomes); + defer select.cancelDiscard(); + + select.concurrent(.op, f, args) catch |err| switch (err) { + error.ConcurrencyUnavailable => return .failed, + }; + select.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) { + error.ConcurrencyUnavailable => return .failed, + }; + + return switch (select.await() catch return .canceled) { + .op => |result| if (result) |_| .ok else |err| switch (err) { + error.Canceled => .canceled, + else => .failed, + }, + // A canceled sleep means this task is being torn down, not that the + // peer went idle. + .expiry => |result| if (result) |_| .timed_out else |_| .canceled, + }; +} + +fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void { + return budget.sleep(io); +} + +fn bump(counter: *std.atomic.Value(u64)) void { + _ = counter.fetchAdd(1, .monotonic); +} + +/// The composition root's entry point: bind, serve, release. A bind failure is +/// warned and swallowed (ruling 1, the web precedent): DoH failing to come up +/// must not stop nxdns answering plain DNS. +pub fn serve( + gpa: Allocator, + io: std.Io, + endpoint: model.TlsEndpoint, + h: *handler.Handler, + certs: *cert_store.CertStore, +) void { + const bind_address = net.IpAddress.parse(endpoint.bind, endpoint.port) catch { + log.warn("doh_server.bind '{s}' is not an IP address; DoH is disabled", .{endpoint.bind}); + return; + }; + + var server: DohServer = DohServer.listen(gpa, io, bind_address, h, certs, .{}) catch |err| { + log.warn("doh listener cannot listen on {s}:{d}: {t}", .{ endpoint.bind, endpoint.port, err }); + return; + }; + defer server.deinit(gpa, io); + + log.info("doh listener on {f}", .{server.boundAddress()}); + server.serve(io); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const build_options = @import("build_options"); +const fixtures = @import("test_fixtures"); +const testing = std.testing; + +const header_mod = @import("../dns/header.zig"); +const packet = @import("../dns/packet.zig"); +const records = @import("../local/records.zig"); +const local_tables_mod = @import("local_tables.zig"); +const response = @import("../filter/response.zig"); +const types = @import("../dns/types.zig"); + +fn testConns(count: usize) ![]DohServer.Conn { + const conns = try testing.allocator.alloc(DohServer.Conn, count); + for (conns) |*conn| conn.conn_state = .free; + return conns; +} + +test "the connection pool hands out every slot once, then refuses" { + const conns = try testConns(2); + defer testing.allocator.free(conns); + + try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot); + conns[0].conn_state = .active; + try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot); + conns[1].conn_state = .active; + try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false))); +} + +test "a closing slot is not reused until it is free" { + const conns = try testConns(1); + defer testing.allocator.free(conns); + + conns[0].conn_state = .closing; + try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false))); + conns[0].conn_state = .free; + try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot); +} + +test "shutdown outranks capacity and does not consume the slot" { + const conns = try testConns(1); + defer testing.allocator.free(conns); + + try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true))); + try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot); +} + +test "framesBody sees framing in either header and none in content-length: 0" { + try testing.expect(framesBody(.chunked, null)); + try testing.expect(framesBody(.none, 4)); + try testing.expect(!framesBody(.none, null)); + try testing.expect(!framesBody(.none, 0)); +} + +test "framesUnreadableBody flags body framing only on bodyless methods" { + try testing.expect(framesUnreadableBody(.GET, .none, 4)); + try testing.expect(framesUnreadableBody(.GET, .chunked, null)); + try testing.expect(framesUnreadableBody(.HEAD, .none, 1)); + try testing.expect(!framesUnreadableBody(.GET, .none, null)); + try testing.expect(!framesUnreadableBody(.GET, .none, 0)); + try testing.expect(!framesUnreadableBody(.POST, .none, 4)); + try testing.expect(!framesUnreadableBody(.POST, .chunked, null)); +} + +test "dnsParam finds the value among other parameters" { + try testing.expectEqualStrings("AAAB", dnsParam("dns=AAAB").value); + try testing.expectEqualStrings("AAAB", dnsParam("ct=x&dns=AAAB&other=1").value); + // An empty value is found here and rejected by the decoder. + try testing.expectEqualStrings("", dnsParam("dns=").value); +} + +test "dnsParam refuses a missing and a duplicated parameter" { + try testing.expectEqual(.missing, std.meta.activeTag(dnsParam(""))); + try testing.expectEqual(.missing, std.meta.activeTag(dnsParam("ct=x"))); + // "dns" as a prefix of another key is not the dns parameter. + try testing.expectEqual(.missing, std.meta.activeTag(dnsParam("dnsx=AAAB"))); + try testing.expectEqual(.duplicate, std.meta.activeTag(dnsParam("dns=AAAB&dns=AAAB"))); +} + +test "decodeDnsValue round-trips base64url without padding" { + const query = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00"; + var encoded: [64]u8 = undefined; + const value = std.base64.url_safe_no_pad.Encoder.encode(&encoded, query); + + var decoded: [64]u8 = undefined; + try testing.expectEqualSlices(u8, query, try decodeDnsValue(value, &decoded)); +} + +test "decodeDnsValue rejects padding, whitespace, percent and the empty value" { + var buf: [64]u8 = undefined; + try testing.expectError(error.Invalid, decodeDnsValue("", &buf)); + try testing.expectError(error.Invalid, decodeDnsValue("YWJj=", &buf)); + try testing.expectError(error.Invalid, decodeDnsValue("YW Jj", &buf)); + try testing.expectError(error.Invalid, decodeDnsValue("YWJj\t", &buf)); + try testing.expectError(error.Invalid, decodeDnsValue("YW%3D", &buf)); + try testing.expectError(error.Invalid, decodeDnsValue("Y!Jj", &buf)); +} + +test "decodeDnsValue refuses a value larger than its buffer" { + var big: [16]u8 = undefined; + @memset(&big, 'A'); + var small: [4]u8 = undefined; + try testing.expectError(error.Invalid, decodeDnsValue(&big, &small)); +} + +// --------------------------------------------------------------------------- +// integration tests (-Dintegration): loopback DoH over a real TLS handshake +// --------------------------------------------------------------------------- + +const blocking_defaults: model.Blocking = .{}; +const test_blocking: response.Options = .{ + .mode = blocking_defaults.response, + .ttl = blocking_defaults.ttl, +}; +const test_forward_timeout: std.Io.Clock.Duration = .{ + .raw = model.readTimeout(.{}), + .clock = .awake, +}; + +const test_budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(5), .clock = .awake }; + +const local_rows = [_]model.LocalRecord{ + .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5", .ttl = 120 }, +}; + +/// A query for nas.lan A: id 0xBEEF, RD set, one question, no OPT. The harness +/// handler answers it from `local_rows`, never from an upstream. +const query_bytes = + "\xbe\xef\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\x03nas\x03lan\x00\x00\x01\x00\x01"; + +/// Proves every 200 came from the local-records table: any upstream exchange +/// fails the query into SERVFAIL, which the assertions below would catch. +const FailingUpstream = struct { + fn exchangeFn( + ptr: *anyopaque, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) transport.ExchangeError![]u8 { + _ = ptr; + _ = io; + _ = query; + _ = response_buf; + return error.ConnectFailed; + } + + fn client(self: *FailingUpstream) transport.Client { + return .{ .ptr = self, .exchangeFn = exchangeFn }; + } +}; + +/// One whole server: fixture certs written to a tmp dir for the `CertStore`, +/// a local-records handler, and a serving listener on 127.0.0.1. Must not move +/// after `start` — the store borrows the path buffers and the handler borrows +/// the tables. +const Harness = struct { + threaded: std.Io.Threaded, + tmp: testing.TmpDir, + cert_path_buf: [128]u8, + key_path_buf: [128]u8, + store: cert_store.CertStore, + tables: local_tables_mod.LocalTables, + upstream: FailingUpstream, + h: handler.Handler, + server: DohServer, + group: std.Io.Group, + + fn start(hx: *Harness) !void { + hx.threaded = .init(testing.allocator, .{}); + errdefer hx.threaded.deinit(); + const hio = hx.threaded.io(); + + hx.tmp = testing.tmpDir(.{}); + errdefer hx.tmp.cleanup(); + try hx.tmp.dir.writeFile(hio, .{ .sub_path = "cert.pem", .data = fixtures.cert_pem }); + try hx.tmp.dir.writeFile(hio, .{ .sub_path = "key.pem", .data = fixtures.key_pem }); + const cert_path = try std.fmt.bufPrint(&hx.cert_path_buf, ".zig-cache/tmp/{s}/cert.pem", .{hx.tmp.sub_path}); + const key_path = try std.fmt.bufPrint(&hx.key_path_buf, ".zig-cache/tmp/{s}/key.pem", .{hx.tmp.sub_path}); + + hx.store = try cert_store.CertStore.init(testing.allocator, hio, cert_path, key_path, alpn_protocols); + errdefer hx.store.deinit(hio); + + hx.tables = .{ .records = try records.Records.build(testing.allocator, &local_rows) }; + errdefer hx.tables.deinit(testing.allocator); + + hx.upstream = .{}; + hx.h = .{ + .upstream = hx.upstream.client(), + .blocking = test_blocking, + .forward_read_timeout = test_forward_timeout, + .local_tables = &hx.tables, + }; + + const listen_address: net.IpAddress = try .parse("127.0.0.1", 0); + hx.server = try DohServer.listen(testing.allocator, hio, listen_address, &hx.h, &hx.store, .{ + .max_connections = 4, + }); + errdefer hx.server.deinit(testing.allocator, hio); + + hx.group = .init; + try hx.group.concurrent(hio, DohServer.serve, .{ &hx.server, hio }); + } + + fn stop(hx: *Harness) void { + const hio = hx.threaded.io(); + hx.server.deinit(testing.allocator, hio); + hx.group.await(hio) catch |err| switch (err) { + error.Canceled => unreachable, + }; + hx.store.deinit(hio); + hx.tables.deinit(testing.allocator); + hx.tmp.cleanup(); + hx.threaded.deinit(); + } + + fn io(hx: *Harness) std.Io { + return hx.threaded.io(); + } + + fn addr(hx: *const Harness) net.IpAddress { + return hx.server.boundAddress(); + } +}; + +const TestOutcome = union(enum) { + work: anyerror!void, + expiry: std.Io.Cancelable!void, +}; + +/// Runs the client side under a budget so a server that never answers fails +/// the test instead of hanging the run. +fn bounded(io: std.Io, comptime f: anytype, args: std.meta.ArgsTuple(@TypeOf(f))) !void { + var outcomes: [2]TestOutcome = undefined; + var select: std.Io.Select(TestOutcome) = .init(io, &outcomes); + defer select.cancelDiscard(); + + try select.concurrent(.work, f, args); + try select.concurrent(.expiry, expire, .{ io, test_budget }); + + switch (try select.await()) { + .work => |result| return result, + .expiry => |result| { + try result; + return error.TestTimedOut; + }, + } +} + +/// One TLS client connection speaking raw HTTP/1.1 text, the ruling 12 shape: +/// no std.http.Client and no CA ceremony, just `std.crypto.tls.Client` with +/// verification off against the self-signed fixture. +const ClientConn = struct { + stream: net.Stream, + transport_read_buf: [std.crypto.tls.Client.min_buffer_len]u8, + transport_write_buf: [std.crypto.tls.Client.min_buffer_len]u8, + plaintext_read_buf: [4096]u8, + plaintext_write_buf: [4096]u8, + net_reader: net.Stream.Reader, + net_writer: net.Stream.Writer, + client: std.crypto.tls.Client, + + fn connect(self: *ClientConn, io: std.Io, remote: net.IpAddress) !void { + self.stream = try remote.connect(io, .{ .mode = .stream }); + errdefer self.stream.close(io); + + self.net_reader = self.stream.reader(io, &self.transport_read_buf); + self.net_writer = self.stream.writer(io, &self.transport_write_buf); + + var entropy: [std.crypto.tls.Client.Options.entropy_len]u8 = undefined; + io.random(&entropy); + + self.client = try std.crypto.tls.Client.init( + &self.net_reader.interface, + &self.net_writer.interface, + .{ + .host = .no_verification, + .ca = .no_verification, + .read_buffer = &self.plaintext_read_buf, + .write_buffer = &self.plaintext_write_buf, + .entropy = &entropy, + .realtime_now = std.Io.Timestamp.now(io, .real), + }, + ); + try self.net_writer.interface.flush(); + } + + fn send(self: *ClientConn, bytes: []const u8) !void { + try self.client.writer.writeAll(bytes); + try self.client.writer.flush(); + try self.net_writer.interface.flush(); + } + + fn end(self: *ClientConn) !void { + try self.client.end(); + try self.net_writer.interface.flush(); + } + + fn close(self: *ClientConn, io: std.Io) void { + self.stream.close(io); + } +}; + +const ClientResponse = struct { + status: u16, + content_length: usize, + content_type_buf: [64]u8, + content_type_len: usize, + allow_buf: [32]u8, + allow_len: usize, + body_buf: [512]u8, + + fn contentType(self: *const ClientResponse) []const u8 { + return self.content_type_buf[0..self.content_type_len]; + } + + fn allow(self: *const ClientResponse) []const u8 { + return self.allow_buf[0..self.allow_len]; + } + + fn body(self: *const ClientResponse) []const u8 { + return self.body_buf[0..self.content_length]; + } +}; + +/// Reads one HTTP/1.1 response. Header values are copied out because each +/// `takeDelimiter` may invalidate the previous line. +fn readResponse(reader: *std.Io.Reader) !ClientResponse { + var resp: ClientResponse = .{ + .status = 0, + .content_length = 0, + .content_type_buf = undefined, + .content_type_len = 0, + .allow_buf = undefined, + .allow_len = 0, + .body_buf = undefined, + }; + + const status_line = (try reader.takeDelimiter('\n')) orelse return error.TestBadStatusLine; + if (status_line.len < 12 or !std.mem.startsWith(u8, status_line, "HTTP/1.1 ")) { + return error.TestBadStatusLine; + } + resp.status = try std.fmt.parseInt(u16, status_line[9..12], 10); + + while (true) { + const raw = (try reader.takeDelimiter('\n')) orelse return error.TestTruncatedHead; + const line = std.mem.trimEnd(u8, raw, "\r"); + if (line.len == 0) break; + const colon = std.mem.findScalar(u8, line, ':') orelse continue; + const name = line[0..colon]; + const value = std.mem.trim(u8, line[colon + 1 ..], " \t"); + if (std.ascii.eqlIgnoreCase(name, "content-length")) { + resp.content_length = try std.fmt.parseInt(usize, value, 10); + } else if (std.ascii.eqlIgnoreCase(name, "content-type")) { + if (value.len > resp.content_type_buf.len) return error.TestHeaderTooLong; + @memcpy(resp.content_type_buf[0..value.len], value); + resp.content_type_len = value.len; + } else if (std.ascii.eqlIgnoreCase(name, "allow")) { + if (value.len > resp.allow_buf.len) return error.TestHeaderTooLong; + @memcpy(resp.allow_buf[0..value.len], value); + resp.allow_len = value.len; + } + } + + if (resp.content_length > resp.body_buf.len) return error.TestBodyTooLong; + try reader.readSliceAll(resp.body_buf[0..resp.content_length]); + return resp; +} + +fn sendPost(conn: *ClientConn, content_type: []const u8, request_body: []const u8) !void { + var head_buf: [256]u8 = undefined; + const head = try std.fmt.bufPrint( + &head_buf, + "POST {s} HTTP/1.1\r\nhost: doh.test\r\ncontent-type: {s}\r\ncontent-length: {d}\r\n\r\n", + .{ dns_query_path, content_type, request_body.len }, + ); + try conn.client.writer.writeAll(head); + try conn.send(request_body); +} + +/// The local answer is deterministic, so the reply must carry the query's id, +/// exactly one answer, and the fixture address as the final rdata bytes. +fn expectLocalReply(reply: []const u8) !void { + const p = try packet.parse(reply); + try testing.expectEqual(@as(u16, 0xbeef), p.header.id); + try testing.expectEqual(true, p.header.flags.qr); + try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode); + try testing.expectEqual(@as(u16, 1), p.header.ancount); + try testing.expect(std.mem.endsWith(u8, reply, &.{ 192, 168, 1, 5 })); +} + +fn postRoundTrip(io: std.Io, remote: net.IpAddress) anyerror!void { + var conn: ClientConn = undefined; + try conn.connect(io, remote); + defer conn.close(io); + + try sendPost(&conn, doh_client.media_type, query_bytes); + const resp = try readResponse(&conn.client.reader); + + try testing.expectEqual(@as(u16, 200), resp.status); + try testing.expect(doh_client.contentTypeOk(resp.contentType())); + try expectLocalReply(resp.body()); + + try conn.end(); +} + +test "a POST round trip answers from local records" { + if (!build_options.integration) return error.SkipZigTest; + + var hx: Harness = undefined; + try hx.start(); + defer hx.stop(); + + try bounded(hx.io(), postRoundTrip, .{ hx.io(), hx.addr() }); + + const stats = hx.server.snapshotStats(); + try testing.expectEqual(@as(u64, 1), stats.connections); + try testing.expectEqual(@as(u64, 0), stats.bad_requests); + try testing.expectEqual(@as(u64, 0), stats.tls_handshake_failures); +} + +fn getMatchesPost(io: std.Io, remote: net.IpAddress) anyerror!void { + var conn: ClientConn = undefined; + try conn.connect(io, remote); + defer conn.close(io); + + try sendPost(&conn, doh_client.media_type, query_bytes); + const post_resp = try readResponse(&conn.client.reader); + try testing.expectEqual(@as(u16, 200), post_resp.status); + + var encoded: [128]u8 = undefined; + const value = std.base64.url_safe_no_pad.Encoder.encode(&encoded, query_bytes); + var head_buf: [256]u8 = undefined; + const head = try std.fmt.bufPrint( + &head_buf, + "GET {s}?dns={s} HTTP/1.1\r\nhost: doh.test\r\n\r\n", + .{ dns_query_path, value }, + ); + try conn.send(head); + const get_resp = try readResponse(&conn.client.reader); + + try testing.expectEqual(@as(u16, 200), get_resp.status); + try testing.expect(doh_client.contentTypeOk(get_resp.contentType())); + try expectLocalReply(get_resp.body()); + try testing.expectEqualSlices(u8, post_resp.body(), get_resp.body()); + + try conn.end(); +} + +test "a GET round trip returns the same reply bytes as the POST" { + if (!build_options.integration) return error.SkipZigTest; + + var hx: Harness = undefined; + try hx.start(); + defer hx.stop(); + + try bounded(hx.io(), getMatchesPost, .{ hx.io(), hx.addr() }); + try testing.expectEqual(@as(u64, 1), hx.server.snapshotStats().connections); +} + +fn errorMatrix(io: std.Io, remote: net.IpAddress) anyerror!void { + var conn: ClientConn = undefined; + try conn.connect(io, remote); + defer conn.close(io); + + // 415: the right path, the wrong media type. Zero-length on purpose — + // only a refusal with no unread body may keep the connection; a 415 with + // body bytes in flight closes instead (the drain-free test below). + try sendPost(&conn, "text/plain", ""); + try testing.expectEqual(@as(u16, 415), (try readResponse(&conn.client.reader)).status); + + // 405 with the Allow header naming what would have worked. + try conn.send("PUT /dns-query HTTP/1.1\r\nhost: doh.test\r\ncontent-length: 0\r\n\r\n"); + const put_resp = try readResponse(&conn.client.reader); + try testing.expectEqual(@as(u16, 405), put_resp.status); + try testing.expectEqualStrings("GET, POST", put_resp.allow()); + + // 404: only /dns-query exists. + try conn.send("GET /api/health HTTP/1.1\r\nhost: doh.test\r\n\r\n"); + try testing.expectEqual(@as(u16, 404), (try readResponse(&conn.client.reader)).status); + + // 400: padded base64 is invalid per RFC 8484 §6. + try conn.send("GET /dns-query?dns=YWJj= HTTP/1.1\r\nhost: doh.test\r\n\r\n"); + try testing.expectEqual(@as(u16, 400), (try readResponse(&conn.client.reader)).status); + + try conn.end(); +} + +test "the error matrix: 415, 405, 404 and 400 on one keep-alive connection" { + if (!build_options.integration) return error.SkipZigTest; + + var hx: Harness = undefined; + try hx.start(); + defer hx.stop(); + + try bounded(hx.io(), errorMatrix, .{ hx.io(), hx.addr() }); + + const stats = hx.server.snapshotStats(); + try testing.expectEqual(@as(u64, 1), stats.connections); + try testing.expectEqual(@as(u64, 4), stats.bad_requests); + try testing.expectEqual(@as(u64, 0), stats.connection_errors); +} + +/// A closed connection ends with close_notify (a short read) when the server +/// beat the client to the socket, or a reset when the close already landed; +/// both are the same fact for these tests: no further response can arrive. +fn expectEof(reader: *std.Io.Reader) !void { + var byte: [1]u8 = undefined; + const got = reader.readSliceShort(&byte) catch 0; + try testing.expectEqual(@as(usize, 0), got); +} + +fn bodiedGetIsRejectedAndClosed(io: std.Io, remote: net.IpAddress) anyerror!void { + var encoded: [128]u8 = undefined; + const value = std.base64.url_safe_no_pad.Encoder.encode(&encoded, query_bytes); + + // content-length framing on a GET: the four body bytes would otherwise be + // parsed as the next request head. + { + var conn: ClientConn = undefined; + try conn.connect(io, remote); + defer conn.close(io); + + var head_buf: [256]u8 = undefined; + const head = try std.fmt.bufPrint( + &head_buf, + "GET {s}?dns={s} HTTP/1.1\r\nhost: doh.test\r\ncontent-length: 4\r\n\r\nHTTP", + .{ dns_query_path, value }, + ); + try conn.send(head); + try testing.expectEqual(@as(u16, 400), (try readResponse(&conn.client.reader)).status); + try expectEof(&conn.client.reader); + } + + // chunked framing on a GET is the same refusal. + { + var conn: ClientConn = undefined; + try conn.connect(io, remote); + defer conn.close(io); + + var head_buf: [256]u8 = undefined; + const head = try std.fmt.bufPrint( + &head_buf, + "GET {s}?dns={s} HTTP/1.1\r\nhost: doh.test\r\ntransfer-encoding: chunked\r\n\r\n0\r\n\r\n", + .{ dns_query_path, value }, + ); + try conn.send(head); + try testing.expectEqual(@as(u16, 400), (try readResponse(&conn.client.reader)).status); + try expectEof(&conn.client.reader); + } +} + +test "a GET that frames a body is refused and the connection closes" { + if (!build_options.integration) return error.SkipZigTest; + + var hx: Harness = undefined; + try hx.start(); + defer hx.stop(); + + try bounded(hx.io(), bodiedGetIsRejectedAndClosed, .{ hx.io(), hx.addr() }); + + const stats = hx.server.snapshotStats(); + try testing.expectEqual(@as(u64, 2), stats.connections); + try testing.expectEqual(@as(u64, 2), stats.bad_requests); + try testing.expectEqual(@as(u64, 0), stats.connection_errors); +} + +fn framedRequestsKeepTheirRoutedStatus(io: std.Io, remote: net.IpAddress) anyerror!void { + // A framed DELETE of the endpoint is still a method miss: 405 with Allow, + // and the connection closes so the framed bytes are never read. + { + var conn: ClientConn = undefined; + try conn.connect(io, remote); + defer conn.close(io); + + try conn.send("DELETE /dns-query HTTP/1.1\r\nhost: doh.test\r\ncontent-length: 4\r\n\r\nHTTP"); + const resp = try readResponse(&conn.client.reader); + try testing.expectEqual(@as(u16, 405), resp.status); + try testing.expectEqualStrings("GET, POST", resp.allow()); + try expectEof(&conn.client.reader); + } + + // A framed GET beside the endpoint is still a path miss: 404, closed. + { + var conn: ClientConn = undefined; + try conn.connect(io, remote); + defer conn.close(io); + + try conn.send("GET /api/health HTTP/1.1\r\nhost: doh.test\r\ntransfer-encoding: chunked\r\n\r\n0\r\n\r\n"); + try testing.expectEqual(@as(u16, 404), (try readResponse(&conn.client.reader)).status); + try expectEof(&conn.client.reader); + } +} + +test "a framed request keeps its routed status and the connection closes" { + if (!build_options.integration) return error.SkipZigTest; + + var hx: Harness = undefined; + try hx.start(); + defer hx.stop(); + + try bounded(hx.io(), framedRequestsKeepTheirRoutedStatus, .{ hx.io(), hx.addr() }); + + const stats = hx.server.snapshotStats(); + try testing.expectEqual(@as(u64, 2), stats.connections); + try testing.expectEqual(@as(u64, 2), stats.bad_requests); + try testing.expectEqual(@as(u64, 0), stats.connection_errors); +} + +fn refusalsDoNotDrainUnfinishedBodies(io: std.Io, remote: net.IpAddress) anyerror!void { + // A chunked POST beside the endpoint, first chunk sent, terminal chunk + // never sent. A server that drained the body before refusing would wait + // here forever (the `bounded` budget fails the test); the fix answers 404 + // immediately and hangs up without reading a body byte. + { + var conn: ClientConn = undefined; + try conn.connect(io, remote); + defer conn.close(io); + + try conn.send("POST /wrong-path HTTP/1.1\r\nhost: doh.test\r\n" ++ + "content-type: application/dns-message\r\n" ++ + "transfer-encoding: chunked\r\n\r\n4\r\nHTTP\r\n"); + try testing.expectEqual(@as(u16, 404), (try readResponse(&conn.client.reader)).status); + try expectEof(&conn.client.reader); + } + + // A wrong-media-type POST declaring 4096 body bytes it never sends: the + // 415 must arrive without the server waiting for the missing bytes. + { + var conn: ClientConn = undefined; + try conn.connect(io, remote); + defer conn.close(io); + + try conn.send("POST /dns-query HTTP/1.1\r\nhost: doh.test\r\n" ++ + "content-type: text/plain\r\ncontent-length: 4096\r\n\r\n"); + try testing.expectEqual(@as(u16, 415), (try readResponse(&conn.client.reader)).status); + try expectEof(&conn.client.reader); + } +} + +test "a refusal with an unread body answers at once and closes, never draining" { + if (!build_options.integration) return error.SkipZigTest; + + var hx: Harness = undefined; + try hx.start(); + defer hx.stop(); + + try bounded(hx.io(), refusalsDoNotDrainUnfinishedBodies, .{ hx.io(), hx.addr() }); + + const stats = hx.server.snapshotStats(); + try testing.expectEqual(@as(u64, 2), stats.connections); + try testing.expectEqual(@as(u64, 2), stats.bad_requests); + try testing.expectEqual(@as(u64, 0), stats.connection_errors); +} + +fn postWithConnectionClose(io: std.Io, remote: net.IpAddress) anyerror!void { + var conn: ClientConn = undefined; + try conn.connect(io, remote); + defer conn.close(io); + + var head_buf: [256]u8 = undefined; + const head = try std.fmt.bufPrint( + &head_buf, + "POST {s} HTTP/1.1\r\nhost: doh.test\r\nconnection: close\r\ncontent-type: {s}\r\ncontent-length: {d}\r\n\r\n", + .{ dns_query_path, doh_client.media_type, query_bytes.len }, + ); + try conn.client.writer.writeAll(head); + try conn.send(query_bytes); + + const resp = try readResponse(&conn.client.reader); + try testing.expectEqual(@as(u16, 200), resp.status); + try expectLocalReply(resp.body()); + try expectEof(&conn.client.reader); +} + +test "a successful POST with connection: close is answered, then the server hangs up" { + if (!build_options.integration) return error.SkipZigTest; + + var hx: Harness = undefined; + try hx.start(); + defer hx.stop(); + + try bounded(hx.io(), postWithConnectionClose, .{ hx.io(), hx.addr() }); + + const stats = hx.server.snapshotStats(); + try testing.expectEqual(@as(u64, 1), stats.connections); + try testing.expectEqual(@as(u64, 0), stats.bad_requests); + try testing.expectEqual(@as(u64, 0), stats.connection_errors); +} + +fn dropClosesConnection(io: std.Io, remote: net.IpAddress) anyerror!void { + // POST: a well-formed request whose body is not a DNS message drops in + // the handler; ruling 2 says 400 and the connection ends. + { + var conn: ClientConn = undefined; + try conn.connect(io, remote); + defer conn.close(io); + + try sendPost(&conn, doh_client.media_type, "xx"); + try testing.expectEqual(@as(u16, 400), (try readResponse(&conn.client.reader)).status); + try expectEof(&conn.client.reader); + } + + // GET: the same garbage, validly base64url-encoded, drops the same way. + { + var conn: ClientConn = undefined; + try conn.connect(io, remote); + defer conn.close(io); + + var encoded: [8]u8 = undefined; + const value = std.base64.url_safe_no_pad.Encoder.encode(&encoded, "xx"); + var head_buf: [128]u8 = undefined; + const head = try std.fmt.bufPrint( + &head_buf, + "GET {s}?dns={s} HTTP/1.1\r\nhost: doh.test\r\n\r\n", + .{ dns_query_path, value }, + ); + try conn.send(head); + try testing.expectEqual(@as(u16, 400), (try readResponse(&conn.client.reader)).status); + try expectEof(&conn.client.reader); + } +} + +test "a handler drop answers 400 and the connection closes, POST and GET" { + if (!build_options.integration) return error.SkipZigTest; + + var hx: Harness = undefined; + try hx.start(); + defer hx.stop(); + + try bounded(hx.io(), dropClosesConnection, .{ hx.io(), hx.addr() }); + + const stats = hx.server.snapshotStats(); + try testing.expectEqual(@as(u64, 2), stats.connections); + try testing.expectEqual(@as(u64, 2), stats.bad_requests); + try testing.expectEqual(@as(u64, 0), stats.connection_errors); +} + +fn twoPostsOneConnection(io: std.Io, remote: net.IpAddress) anyerror!void { + var conn: ClientConn = undefined; + try conn.connect(io, remote); + defer conn.close(io); + + for (0..2) |_| { + try sendPost(&conn, doh_client.media_type, query_bytes); + const resp = try readResponse(&conn.client.reader); + try testing.expectEqual(@as(u16, 200), resp.status); + try expectLocalReply(resp.body()); + } + + try conn.end(); +} + +test "keep-alive: two requests are answered on one connection" { + if (!build_options.integration) return error.SkipZigTest; + + var hx: Harness = undefined; + try hx.start(); + defer hx.stop(); + + try bounded(hx.io(), twoPostsOneConnection, .{ hx.io(), hx.addr() }); + + const stats = hx.server.snapshotStats(); + try testing.expectEqual(@as(u64, 1), stats.connections); + try testing.expectEqual(@as(u64, 0), stats.bad_requests); + try testing.expectEqual(@as(u64, 2), hx.h.stats.local_answers.load(.monotonic)); +} diff --git a/src/server/dot_server.zig b/src/server/dot_server.zig new file mode 100644 index 0000000..c12f9c7 --- /dev/null +++ b/src/server/dot_server.zig @@ -0,0 +1,1136 @@ +//! The DoT listener (RFC 7858): the TCP/53 loop over a TLS stream. +//! +//! This file mirrors `tcp_server.zig` — same slots, same claim rule, same +//! shutdown paths, same idle race — with three differences: +//! +//! - After the TCP accept, the certificate generation is pinned with +//! `CertStore.acquire` and the mbedTLS handshake runs under the same race +//! budget as every other per-connection operation, so a client that stalls +//! mid-handshake cannot pin a connection slot. +//! - The framed-message loop reads and writes through +//! `tls_server.ServerStream`, and closing the stream sends close_notify +//! before the TCP close. A transport EOF without close_notify surfaces as a +//! read failure, so a truncated connection is counted, never mistaken for a +//! clean end. +//! - `ServerStream.accept` heap-allocates the mbedTLS ssl context, so unlike +//! TCP/53 each connection costs one allocation. The pool itself is still +//! fixed and pre-allocated. +//! +//! ALPN belongs to the `CertStore`'s `ServerContext` (set at store init); +//! this listener only acquires entries. + +const std = @import("std"); +const address = @import("../platform/address.zig"); +const cert_store = @import("cert_store.zig"); +const handler = @import("handler.zig"); +const tls_server = @import("../platform/tls_server.zig"); +const transport = @import("../upstream/transport.zig"); + +const log = std.log.scoped(.dot_server); + +/// Plaintext staging for `ServerStream`: the framing bytes and the decrypted +/// record tail pass through here, while whole messages go straight to +/// `Conn.query`/`Conn.reply`. +const stream_buffer_len = 1024; + +/// How long the accept loop waits after an unexpected accept failure, so a +/// persistent one cannot turn the loop into a spin. +const retry_delay: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake }; + +pub const Options = struct { + max_connections: u16 = 64, + /// RFC 7766 §6.2.3 recommends a few seconds of idle tolerance, and the + /// handshake runs under the same budget. + idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake }, +}; + +pub const Stats = struct { + /// TCP connections accepted, whether or not the handshake succeeded. + connections: std.atomic.Value(u64) = .init(0), + /// Handshakes that failed or outran the idle budget. + tls_handshake_failures: std.atomic.Value(u64) = .init(0), + idle_timeouts: std.atomic.Value(u64) = .init(0), + connection_errors: std.atomic.Value(u64) = .init(0), + rejected_at_capacity: std.atomic.Value(u64) = .init(0), + rejected_at_shutdown: std.atomic.Value(u64) = .init(0), + accept_errors: std.atomic.Value(u64) = .init(0), +}; + +/// The milestone-10 ruling 10 counters, the shape `metrics.counterGroup` +/// walks for the `nxdns_dot_server_*` families. +pub const StatsSnapshot = struct { + connections: u64, + tls_handshake_failures: u64, + idle_timeouts: u64, + connection_errors: u64, +}; + +/// Lifecycle of the accept loop. `serve` claims `.serving`, `deinit` publishes +/// `.closing`, and the two meet at `stopped` so no task touches a connection +/// slot after it is freed. +const State = enum(u32) { idle, serving, closing }; + +/// `.closing` exists so `deinit` never shuts down a descriptor that its own +/// task is about to close: the transition to `.closing` happens under the mutex +/// before the close, and `deinit` only touches `.active` slots. +const ConnState = enum { free, active, closing }; + +/// Why the accept loop stopped, which decides what happens to the connections +/// still in flight. +const Stop = enum { + /// `deinit` published `.closing`. It has already shut every live connection + /// down, so each one is unblocked and finishing on its own. + closing, + /// This task is being canceled. Nothing has touched the connections. + canceled, +}; + +/// What the accept loop does with a stream it has just accepted. +const Claim = union(enum) { + /// The stream owns `conns[index]`. + slot: usize, + /// Every slot is taken. The stream is closed and the loop continues. + at_capacity, + /// `deinit` has started. The stream is closed and the loop returns. + shutting_down, +}; + +pub const DotServer = struct { + server: std.Io.net.Server, + handler: *handler.Handler, + certs: *cert_store.CertStore, + /// Kept for the per-connection ssl context `ServerStream.accept` + /// allocates and `close` frees. + gpa: std.mem.Allocator, + conns: []Conn, + mutex: std.Io.Mutex, + /// Guarded by `mutex`. `deinit` sets it in the same critical section that + /// shuts the active connections down, so a stream that arrives after that + /// scan can never claim a slot the scan will not visit again. + shutdown_begun: bool, + options: Options, + stats: Stats, + state: std.atomic.Value(State), + stopped: std.Io.Event, + + /// One slot is ~137 KiB — the same two message ceilings as TCP/53 plus the + /// `ServerStream` bookkeeping — so the default 64 connections stay inside + /// the PLAN §18 budget. + pub const Conn = struct { + query: [transport.max_message_len]u8, + reply: [transport.max_message_len]u8, + read_buf: [stream_buffer_len]u8, + write_buf: [stream_buffer_len]u8, + /// The handler's per-query working memory. It belongs to the slot so + /// that answering a message allocates nothing, and a connection is + /// answered serially, so one query uses it at a time. + scratch: handler.Scratch, + /// Pinned once its `accept` succeeds: mbedTLS holds a pointer to it, + /// and the slot never moves. + tls: tls_server.ServerStream, + stream: std.Io.net.Stream, + /// The client, read off the accepted socket once at claim time: every + /// message on this connection comes from the same peer, and the handler + /// needs it for rate limiting, groups and the query log. + peer: std.Io.net.IpAddress, + /// Guarded by `DotServer.mutex`. + state: ConnState, + }; + + pub const ListenError = std.Io.net.IpAddress.ListenError || error{OutOfMemory}; + + pub fn listen( + gpa: std.mem.Allocator, + io: std.Io, + listen_address: std.Io.net.IpAddress, + h: *handler.Handler, + certs: *cert_store.CertStore, + options: Options, + ) ListenError!DotServer { + std.debug.assert(options.max_connections > 0); + + const conns = try gpa.alloc(Conn, options.max_connections); + errdefer gpa.free(conns); + for (conns) |*conn| conn.state = .free; + + const local = listen_address; + const server = try local.listen(io, .{ .reuse_address = true }); + + return .{ + .server = server, + .handler = h, + .certs = certs, + .gpa = gpa, + .conns = conns, + .mutex = .init, + .shutdown_begun = false, + .options = options, + .stats = .{}, + .state = .init(.idle), + .stopped = .unset, + }; + } + + /// The kernel-assigned address. A port of 0 in `listen` resolves here. + pub fn boundAddress(self: *const DotServer) std.Io.net.IpAddress { + return self.server.socket.address; + } + + /// Accept loop. Returns when the task is canceled or `deinit` stops it. + pub fn serve(self: *DotServer, io: std.Io) void { + if (self.state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return; + + var group: std.Io.Group = .init; + switch (self.acceptLoop(io, &group)) { + // `deinit` shut every live connection down before it published + // `.closing`, so each one is already unblocked and ending on its + // own. Awaiting them means a half-written reply still goes out + // whole, and the wait is bounded by the shutdown, not the client. + .closing => { + const prev = io.swapCancelProtection(.blocked); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; + _ = io.swapCancelProtection(prev); + }, + // Nothing has shut these connections down: `deinit` cannot run + // until this task returns, and RFC 7766 lets a client hold a + // connection open forever by asking again inside the idle budget. + // Draining here would therefore let one client stall the whole + // process's shutdown for as long as it likes. `cancel` requests + // cancellation and joins, so the slots are still quiet — and the + // buffers still unreferenced — by the time `serve` returns; the + // price is the one reply that was mid-write. + .canceled => group.cancel(io), + } + + self.stopped.set(io); + } + + pub fn deinit(self: *DotServer, io: std.Io) void { + const was_serving = self.state.swap(.closing, .acq_rel) == .serving; + + // Shutting the listening socket down is the documented way to unblock a + // pending `accept`: it fails with `error.SocketNotListening`. + const listener: std.Io.net.Stream = .{ .socket = self.server.socket }; + listener.shutdown(io, .both) catch |err| { + log.debug("dot listener shutdown failed: {t}", .{err}); + }; + + // A live connection is blocked in a read that only the idle budget + // would end, which is seconds away. Shutting each one down bounds this, + // and the same critical section closes the door on new connections. + self.beginShutdown(io); + + if (was_serving) self.stopped.waitUncancelable(io); + + self.server.deinit(io); + self.gpa.free(self.conns); + self.* = undefined; + } + + pub fn snapshotStats(self: *const DotServer) StatsSnapshot { + return .{ + .connections = self.stats.connections.load(.monotonic), + .tls_handshake_failures = self.stats.tls_handshake_failures.load(.monotonic), + .idle_timeouts = self.stats.idle_timeouts.load(.monotonic), + .connection_errors = self.stats.connection_errors.load(.monotonic), + }; + } + + fn acceptLoop(self: *DotServer, io: std.Io, group: *std.Io.Group) Stop { + while (self.state.load(.acquire) == .serving) { + const stream = self.server.accept(io) catch |err| switch (err) { + error.Canceled => return .canceled, + // `deinit` shuts the listening socket down to unblock exactly + // this call, so it is the shutdown path arriving early. + error.SocketNotListening => return .closing, + else => { + bump(&self.stats.accept_errors); + log.debug("dot accept failed: {t}", .{err}); + retry_delay.sleep(io) catch return .canceled; + continue; + }, + }; + + const index = switch (self.claim(io, stream)) { + .slot => |index| index, + // Refusing now is honest; a queue would only hide the overload. + .at_capacity => { + bump(&self.stats.rejected_at_capacity); + stream.close(io); + continue; + }, + // `deinit` will not see this stream in any slot, so serving it + // would hold `deinit` for the whole idle budget. + .shutting_down => { + bump(&self.stats.rejected_at_shutdown); + stream.close(io); + return .closing; + }, + }; + + group.concurrent(io, serveConn, .{ self, io, index }) catch |err| switch (err) { + error.ConcurrencyUnavailable => { + bump(&self.stats.rejected_at_capacity); + self.finish(io, index); + continue; + }, + }; + + bump(&self.stats.connections); + } + + // The loop condition failed, which only `deinit` can cause. + return .closing; + } + + fn serveConn(self: *DotServer, io: std.Io, index: usize) void { + defer self.finish(io, index); + + const conn = &self.conns[index]; + const budget = self.options.idle_timeout; + + // Pins the certificate generation for the whole connection: a reload + // that lands mid-stream retires the entry, and this reference keeps + // the old context alive until the release below. + const entry = self.certs.acquire(io); + defer self.certs.release(io, entry); + + var handshook = false; + switch (race(io, budget, handshake, .{ conn, self.gpa, &entry.ctx, io, &handshook })) { + .ok => {}, + // The select can report the expiry or the cancellation after the + // handshake has in fact succeeded. The flag is written before the + // race joins its tasks, so a TLS context that exists is closed on + // every path, exactly once. + .canceled => { + if (handshook) conn.tls.close(self.gpa); + return; + }, + // A stalled handshake is refused like a broken one: it must not + // pin a connection slot for longer than the idle budget. + .timed_out, .failed => { + if (handshook) conn.tls.close(self.gpa); + bump(&self.stats.tls_handshake_failures); + return; + }, + } + // Sends close_notify and frees the ssl context; `finish` closes the + // TCP stream afterwards. + defer conn.tls.close(self.gpa); + + const reader = conn.tls.reader(); + const writer = conn.tls.writer(); + + while (true) { + var prefix: [transport.prefix_len]u8 = undefined; + var got: usize = 0; + switch (race(io, budget, readPrefix, .{ reader, &prefix, &got })) { + .ok => {}, + .timed_out => { + bump(&self.stats.idle_timeouts); + return; + }, + .canceled => return, + // A transport EOF without close_notify lands here too: the + // stream reads it as a truncation, never as a clean end. + .failed => { + bump(&self.stats.connection_errors); + return; + }, + } + + // A client that sent close_notify between messages has finished + // asking, which is the normal end of a connection, not a failure. + if (got == 0) return; + if (got != transport.prefix_len) { + bump(&self.stats.connection_errors); + return; + } + + // RFC 1035 §4.2.2 gives no meaning to a zero-length message, and + // the prefix is a u16 so it can never exceed `max_message_len`. + const len = transport.parsePrefix(prefix); + if (len == 0) { + bump(&self.stats.connection_errors); + return; + } + + switch (race(io, budget, readBody, .{ reader, conn.query[0..len] })) { + .ok => {}, + .canceled => return, + // A half-sent message is a broken peer, not an idle one. + .timed_out, .failed => { + bump(&self.stats.connection_errors); + return; + }, + } + + const outcome = self.handler.handle( + io, + .tcp, + address.NetAddress.fromIp(conn.peer), + conn.query[0..len], + &conn.reply, + &conn.scratch, + ); + const bytes = switch (outcome) { + // There is no framing for "no answer", so the connection ends. + .drop => return, + .reply => |b| b, + }; + + const out = transport.framePrefix(@intCast(bytes.len)); + switch (race(io, budget, writeReply, .{ writer, &out, bytes })) { + .ok => {}, + .canceled => return, + .timed_out, .failed => { + bump(&self.stats.connection_errors); + return; + }, + } + } + } + + fn claim(self: *DotServer, io: std.Io, stream: std.Io.net.Stream) Claim { + // Uncancelable: this section takes no Io and never blocks on a peer, so + // it cannot deadlock, and losing the lock mid-update would leak a slot. + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + const outcome = decideClaim(self.conns, self.shutdown_begun); + switch (outcome) { + .slot => |index| { + self.conns[index].stream = stream; + self.conns[index].peer = stream.socket.address; + self.conns[index].state = .active; + }, + .at_capacity, .shutting_down => {}, + } + return outcome; + } + + fn finish(self: *DotServer, io: std.Io, index: usize) void { + const conn = &self.conns[index]; + + self.mutex.lockUncancelable(io); + conn.state = .closing; + self.mutex.unlock(io); + + // The socket is released even when this task is being torn down: the + // next cancelable call would otherwise skip the close. + const prev = io.swapCancelProtection(.blocked); + conn.stream.close(io); + _ = io.swapCancelProtection(prev); + + self.mutex.lockUncancelable(io); + conn.state = .free; + self.mutex.unlock(io); + } + + /// Closes the door on new connections and unblocks the live ones. Both + /// happen under one hold of the mutex: a `claim` that runs before this + /// leaves an `.active` slot the loop below shuts down, and a `claim` that + /// runs after it reads `shutdown_begun` and takes no slot at all. + fn beginShutdown(self: *DotServer, io: std.Io) void { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + self.shutdown_begun = true; + + for (self.conns) |*conn| { + if (conn.state != .active) continue; + conn.stream.shutdown(io, .both) catch |err| { + log.debug("dot connection shutdown failed: {t}", .{err}); + }; + } + } +}; + +/// The capacity rule, without the mutex, so it is testable without a backend. +fn firstFree(conns: []const DotServer.Conn) ?usize { + for (conns, 0..) |*conn, index| { + if (conn.state == .free) return index; + } + return null; +} + +/// The whole claim rule, without the mutex. Shutdown outranks capacity: a free +/// slot is still refused once `deinit` has passed the connections. +fn decideClaim(conns: []const DotServer.Conn, shutdown_begun: bool) Claim { + if (shutdown_begun) return .shutting_down; + const index = firstFree(conns) orelse return .at_capacity; + return .{ .slot = index }; +} + +const Outcome = union(enum) { + op: anyerror!void, + expiry: std.Io.Cancelable!void, +}; + +const Result = enum { ok, timed_out, failed, canceled }; + +/// Runs one connection operation against the idle budget and cancels the loser. +fn race( + io: std.Io, + budget: std.Io.Clock.Duration, + comptime f: anytype, + args: std.meta.ArgsTuple(@TypeOf(f)), +) Result { + var outcomes: [2]Outcome = undefined; + var select: std.Io.Select(Outcome) = .init(io, &outcomes); + defer select.cancelDiscard(); + + select.concurrent(.op, f, args) catch |err| switch (err) { + error.ConcurrencyUnavailable => return .failed, + }; + select.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) { + error.ConcurrencyUnavailable => return .failed, + }; + + return switch (select.await() catch return .canceled) { + .op => |result| if (result) |_| .ok else |err| switch (err) { + error.Canceled => .canceled, + else => .failed, + }, + // A canceled sleep means this task is being torn down, not that the + // client went idle. + .expiry => |result| if (result) |_| .timed_out else |_| .canceled, + }; +} + +fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void { + return budget.sleep(io); +} + +/// `handshook` is set only after `accept` returned, so `serveConn` knows on +/// the losing race paths whether a TLS context exists that must be closed. +fn handshake( + conn: *DotServer.Conn, + gpa: std.mem.Allocator, + ctx: *tls_server.ServerContext, + io: std.Io, + handshook: *bool, +) anyerror!void { + try conn.tls.accept(gpa, ctx, io, &conn.stream, &conn.read_buf, &conn.write_buf); + handshook.* = true; +} + +/// `readSliceShort` rather than `readSliceAll`: a zero-length read is a client +/// that sent close_notify between messages, and only a partial prefix is an +/// error. +fn readPrefix(reader: *std.Io.Reader, buf: *[transport.prefix_len]u8, out_len: *usize) anyerror!void { + out_len.* = try reader.readSliceShort(buf); +} + +fn readBody(reader: *std.Io.Reader, buf: []u8) anyerror!void { + return reader.readSliceAll(buf); +} + +fn writeReply(writer: *std.Io.Writer, prefix: *const [transport.prefix_len]u8, bytes: []const u8) anyerror!void { + try writer.writeAll(prefix); + try writer.writeAll(bytes); + try writer.flush(); +} + +fn bump(counter: *std.atomic.Value(u64)) void { + _ = counter.fetchAdd(1, .monotonic); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; +const fixtures = @import("test_fixtures"); +const header = @import("../dns/header.zig"); +const model = @import("../config/model.zig"); +const packet = @import("../dns/packet.zig"); +const response = @import("../filter/response.zig"); +const types = @import("../dns/types.zig"); + +fn testConns(count: usize) ![]DotServer.Conn { + const conns = try testing.allocator.alloc(DotServer.Conn, count); + for (conns) |*conn| conn.state = .free; + return conns; +} + +test "the connection pool hands out every slot once" { + const conns = try testConns(3); + defer testing.allocator.free(conns); + + for (0..conns.len) |expected| { + const index = firstFree(conns) orelse return error.TestUnexpectedResult; + try testing.expectEqual(expected, index); + conns[index].state = .active; + } +} + +test "a full connection pool refuses instead of growing" { + const conns = try testConns(2); + defer testing.allocator.free(conns); + + for (conns) |*conn| conn.state = .active; + try testing.expectEqual(@as(?usize, null), firstFree(conns)); +} + +test "a closing slot is not reused until it is free" { + const conns = try testConns(2); + defer testing.allocator.free(conns); + + conns[0].state = .active; + conns[1].state = .closing; + try testing.expectEqual(@as(?usize, null), firstFree(conns)); + + conns[1].state = .free; + try testing.expectEqual(@as(?usize, 1), firstFree(conns)); +} + +test "a claim takes the first free slot before shutdown" { + const conns = try testConns(2); + defer testing.allocator.free(conns); + + conns[0].state = .active; + try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot); +} + +test "a claim after shutdown is refused even with a free slot" { + const conns = try testConns(2); + defer testing.allocator.free(conns); + + try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true))); + + // The refusal must not consume the slot: `deinit` frees it, nothing else. + try testing.expectEqual(@as(?usize, 0), firstFree(conns)); +} + +test "shutdown outranks capacity" { + const conns = try testConns(1); + defer testing.allocator.free(conns); + + conns[0].state = .active; + try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false))); + try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true))); +} + +test "snapshotStats reports the ruling-10 counters" { + var server: DotServer = undefined; + server.stats = .{}; + + bump(&server.stats.connections); + bump(&server.stats.connections); + bump(&server.stats.tls_handshake_failures); + bump(&server.stats.connection_errors); + + const snapshot = server.snapshotStats(); + try testing.expectEqual(@as(u64, 2), snapshot.connections); + try testing.expectEqual(@as(u64, 1), snapshot.tls_handshake_failures); + try testing.expectEqual(@as(u64, 0), snapshot.idle_timeouts); + try testing.expectEqual(@as(u64, 1), snapshot.connection_errors); +} + +// -- loopback integration tests (gated on -Dintegration) -------------------- +// +// Hermetic: one listener and one client on 127.0.0.1, an in-process fake +// upstream, and the fixture certificate written into a tmp directory for the +// `CertStore`. The whole client side of each test runs as one task raced +// against a budget so nothing can hang. + +const blocking_defaults: model.Blocking = .{}; +const blocking: response.Options = .{ + .mode = blocking_defaults.response, + .ttl = blocking_defaults.ttl, +}; +const forward_timeout: std.Io.Clock.Duration = .{ + .raw = model.readTimeout(.{}), + .clock = .awake, +}; + +/// An upstream and nothing else optional: no filtering, no cache, no log. The +/// listener is what these tests exercise, so the handler is the same bare one +/// its own tests use. +fn bareHandler(client: transport.Client) handler.Handler { + return .{ + .upstream = client, + .blocking = blocking, + .forward_read_timeout = forward_timeout, + }; +} + +const test_budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(5), .clock = .awake }; + +/// Short enough to keep the idle-timeout test quick, long enough that a +/// loopback handshake cannot lose to scheduling and time out on its own. +const short_idle: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(300), .clock = .awake }; + +/// What the store advertises in production; std.crypto.tls.Client cannot send +/// ALPN in 0.16, so this also proves no-ALPN clients still connect. +const dot_alpn: [1:null]?[*:0]const u8 = .{"dot"}; + +/// A query for example.com A: id 0x1234, RD set, one question, no OPT. +const query_bytes = + "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01"; + +/// The matching response: the question echoed plus one A record. +const response_bytes = + "\x12\x34\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01" ++ + "\xc0\x0c\x00\x01\x00\x01\x00\x00\x01\x2c\x00\x04\x5d\xb8\xd8\x22"; + +/// Answers from a fixture and rewrites the ID, which is all the server needs +/// from an upstream. The real clients are exercised by their own tests. +const FakeUpstream = struct { + reply: []const u8, + + fn exchangeFn( + ptr: *anyopaque, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) transport.ExchangeError![]u8 { + _ = io; + const self: *FakeUpstream = @ptrCast(@alignCast(ptr)); + if (self.reply.len > response_buf.len) return error.ResponseTooLarge; + @memcpy(response_buf[0..self.reply.len], self.reply); + const bytes = response_buf[0..self.reply.len]; + packet.setId(bytes, (header.parse(query) catch unreachable).id); + return bytes; + } + + fn client(self: *FakeUpstream) transport.Client { + return .{ .ptr = self, .exchangeFn = exchangeFn }; + } +}; + +/// Fixture PEMs written into a tmp directory and loaded into a `CertStore`, +/// addressed by cwd-relative paths the way `app.zig` hands config paths to +/// the store. Must not move after `init`: the store borrows the path slices. +const CertEnv = struct { + tmp: testing.TmpDir, + cert_path_buf: [128]u8, + key_path_buf: [128]u8, + store: cert_store.CertStore, + + fn init(env: *CertEnv, io: std.Io) !void { + env.tmp = testing.tmpDir(.{}); + errdefer env.tmp.cleanup(); + + try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = fixtures.cert_pem }); + try env.tmp.dir.writeFile(io, .{ .sub_path = "key.pem", .data = fixtures.key_pem }); + const cert_path = try std.fmt.bufPrint(&env.cert_path_buf, ".zig-cache/tmp/{s}/cert.pem", .{env.tmp.sub_path}); + const key_path = try std.fmt.bufPrint(&env.key_path_buf, ".zig-cache/tmp/{s}/key.pem", .{env.tmp.sub_path}); + env.store = try cert_store.CertStore.init(testing.allocator, io, cert_path, key_path, &dot_alpn); + } + + fn deinit(env: *CertEnv, io: std.Io) void { + env.store.deinit(io); + env.tmp.cleanup(); + } +}; + +const TestOutcome = union(enum) { + work: anyerror!void, + expiry: std.Io.Cancelable!void, +}; + +/// Runs the client side under a budget so a server that never answers fails +/// the test instead of hanging the run. +fn bounded(io: std.Io, comptime f: anytype, args: std.meta.ArgsTuple(@TypeOf(f))) !void { + var outcomes: [2]TestOutcome = undefined; + var select: std.Io.Select(TestOutcome) = .init(io, &outcomes); + defer select.cancelDiscard(); + + try select.concurrent(.work, f, args); + try select.concurrent(.expiry, expire, .{ io, test_budget }); + + switch (try select.await()) { + .work => |result| return result, + .expiry => |result| { + try result; + return error.TestTimedOut; + }, + } +} + +/// The counters lag the client's last observable byte on some paths (a client +/// that drops its socket sees nothing after), so those tests wait for the +/// count instead of racing it. +fn waitForCounter(io: std.Io, counter: *std.atomic.Value(u64), want: u64) !void { + const step: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(10), .clock = .awake }; + var attempts: usize = 0; + while (counter.load(.monotonic) < want) : (attempts += 1) { + if (attempts > 500) return error.TestTimedOut; + step.sleep(io) catch return error.TestTimedOut; + } +} + +fn expectAnswersQuery(reply: []const u8) !void { + const p = try packet.parse(reply); + try testing.expectEqual(@as(u16, 0x1234), p.header.id); + try testing.expectEqual(true, p.header.flags.qr); + try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode); + try testing.expectEqual(@as(u16, 1), p.header.ancount); + + const echoed = packet.firstQuestion(p) orelse return error.TestMissingQuestion; + const asked = packet.firstQuestion(try packet.parse(query_bytes)).?; + try testing.expectEqualSlices(u8, asked.name.wire(), echoed.name.wire()); + try testing.expectEqual(types.Type.a, echoed.qtype); +} + +/// One TLS client over one TCP connection. Self-referential — the reader and +/// writer interfaces point into this struct — so it is pinned after `connect`. +const TestTls = struct { + stream: std.Io.net.Stream, + net_reader: std.Io.net.Stream.Reader, + net_writer: std.Io.net.Stream.Writer, + transport_read_buffer: [std.crypto.tls.Client.min_buffer_len]u8, + transport_write_buffer: [std.crypto.tls.Client.min_buffer_len]u8, + plaintext_read_buffer: [4096]u8, + plaintext_write_buffer: [4096]u8, + entropy: [std.crypto.tls.Client.Options.entropy_len]u8, + client: std.crypto.tls.Client, + + fn connect(self: *TestTls, io: std.Io, address_: std.Io.net.IpAddress) !void { + self.stream = try address_.connect(io, .{ .mode = .stream }); + errdefer self.stream.close(io); + + self.net_reader = self.stream.reader(io, &self.transport_read_buffer); + self.net_writer = self.stream.writer(io, &self.transport_write_buffer); + io.random(&self.entropy); + + self.client = try std.crypto.tls.Client.init(&self.net_reader.interface, &self.net_writer.interface, .{ + .host = .no_verification, + .ca = .no_verification, + .read_buffer = &self.plaintext_read_buffer, + .write_buffer = &self.plaintext_write_buffer, + .entropy = &self.entropy, + .realtime_now = std.Io.Timestamp.now(io, .real), + }); + try self.net_writer.interface.flush(); + } + + fn close(self: *TestTls, io: std.Io) void { + self.stream.close(io); + } + + fn sendQuery(self: *TestTls) !void { + try self.client.writer.writeAll(&transport.framePrefix(@intCast(query_bytes.len))); + try self.client.writer.writeAll(query_bytes); + try self.client.writer.flush(); + try self.net_writer.interface.flush(); + } + + fn readReply(self: *TestTls) ![]u8 { + const len = transport.parsePrefix((try self.client.reader.takeArray(transport.prefix_len)).*); + return try self.client.reader.take(len); + } +}; + +/// RFC 7766 §6.2.1.1 over TLS: two framed queries on one connection, answered +/// in order, then a clean close_notify exchange in both directions. +fn dotKeepAlive(io: std.Io, address_: std.Io.net.IpAddress) anyerror!void { + var t: TestTls = undefined; + try t.connect(io, address_); + defer t.close(io); + + for (0..2) |_| { + try t.sendQuery(); + try expectAnswersQuery(try t.readReply()); + } + + try t.client.end(); + try t.net_writer.interface.flush(); + + // The server's close_notify must arrive as a clean end of stream. + var tail: [1]u8 = undefined; + try testing.expectError(error.EndOfStream, t.client.reader.readSliceAll(&tail)); +} + +/// One answered query, then the TCP connection drops with no close_notify. +fn dotDropWithoutCloseNotify(io: std.Io, address_: std.Io.net.IpAddress) anyerror!void { + var t: TestTls = undefined; + try t.connect(io, address_); + defer t.close(io); + + try t.sendQuery(); + try expectAnswersQuery(try t.readReply()); +} + +/// Plain TCP bytes where a ClientHello belongs, then reads until the server +/// gives up (an alert followed by a close, or just the close). +fn dotGarbageHandshake(io: std.Io, address_: std.Io.net.IpAddress) anyerror!void { + var stream = try address_.connect(io, .{ .mode = .stream }); + defer stream.close(io); + + var write_buf: [64]u8 = undefined; + var writer = stream.writer(io, &write_buf); + try writer.interface.writeAll("GET / HTTP/1.1\r\nHost: not-a-tls-client\r\n\r\n"); + try writer.interface.flush(); + + var read_buf: [256]u8 = undefined; + var reader = stream.reader(io, &read_buf); + var sink: [256]u8 = undefined; + while (true) { + const n = reader.interface.readSliceShort(&sink) catch break; + if (n == 0) break; + } +} + +/// Handshakes and then sends nothing, so the server must end the connection +/// on its own — with close_notify, which the client sees as a clean end. +fn dotIdleUntilServerCloses(io: std.Io, address_: std.Io.net.IpAddress) anyerror!void { + var t: TestTls = undefined; + try t.connect(io, address_); + defer t.close(io); + + var tail: [1]u8 = undefined; + try testing.expectError(error.EndOfStream, t.client.reader.readSliceAll(&tail)); +} + +test "dot: two framed queries share one TLS connection" { + const build_options = @import("build_options"); + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var env: CertEnv = undefined; + try env.init(io); + defer env.deinit(io); + + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h = bareHandler(fake.client()); + + const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0); + var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 }); + const server_address = server.boundAddress(); + + var group: std.Io.Group = .init; + try group.concurrent(io, DotServer.serve, .{ &server, io }); + + try bounded(io, dotKeepAlive, .{ io, server_address }); + + // The client saw the server's close_notify, so the connection task has + // already run to completion and the counters are final. + const stats = server.snapshotStats(); + try testing.expectEqual(@as(u64, 1), stats.connections); + try testing.expectEqual(@as(u64, 0), stats.tls_handshake_failures); + try testing.expectEqual(@as(u64, 0), stats.idle_timeouts); + try testing.expectEqual(@as(u64, 0), stats.connection_errors); + try testing.expectEqual(@as(u64, 2), h.stats.queries.load(.monotonic)); + + server.deinit(io); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; +} + +test "dot: a transport EOF without close_notify is a connection error, not a crash" { + const build_options = @import("build_options"); + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var env: CertEnv = undefined; + try env.init(io); + defer env.deinit(io); + + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h = bareHandler(fake.client()); + + const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0); + var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 }); + const server_address = server.boundAddress(); + + var group: std.Io.Group = .init; + try group.concurrent(io, DotServer.serve, .{ &server, io }); + + try bounded(io, dotDropWithoutCloseNotify, .{ io, server_address }); + try waitForCounter(io, &server.stats.connection_errors, 1); + + const stats = server.snapshotStats(); + try testing.expectEqual(@as(u64, 1), stats.connections); + try testing.expectEqual(@as(u64, 0), stats.tls_handshake_failures); + try testing.expectEqual(@as(u64, 1), stats.connection_errors); + try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic)); + + server.deinit(io); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; +} + +test "dot: plain TCP bytes fail the handshake and are counted" { + const build_options = @import("build_options"); + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var env: CertEnv = undefined; + try env.init(io); + defer env.deinit(io); + + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h = bareHandler(fake.client()); + + const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0); + var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 }); + const server_address = server.boundAddress(); + + var group: std.Io.Group = .init; + try group.concurrent(io, DotServer.serve, .{ &server, io }); + + try bounded(io, dotGarbageHandshake, .{ io, server_address }); + try waitForCounter(io, &server.stats.tls_handshake_failures, 1); + + const stats = server.snapshotStats(); + try testing.expectEqual(@as(u64, 1), stats.connections); + try testing.expectEqual(@as(u64, 1), stats.tls_handshake_failures); + try testing.expectEqual(@as(u64, 0), stats.connection_errors); + try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic)); + + server.deinit(io); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; +} + +/// Ruling 6 across a live listener: a reload retires the old generation +/// without touching the connection that pinned it, and the next handshake is +/// served by the new one. std.crypto.tls.Client (0.16) frees the peer chain +/// inside `init` and keeps no field for it, so the identity swap is asserted +/// on the store's entry pointers instead of on the certificates the client saw. +fn dotReloadKeepsOldConnAndServesNew(io: std.Io, address_: std.Io.net.IpAddress, env: *CertEnv) anyerror!void { + var first: TestTls = undefined; + try first.connect(io, address_); + defer first.close(io); + + try first.sendQuery(); + try expectAnswersQuery(try first.readReply()); + + const old_entry = env.store.acquire(io); + defer env.store.release(io, old_entry); + + try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = fixtures.cert2_pem }); + try env.tmp.dir.writeFile(io, .{ .sub_path = "key.pem", .data = fixtures.key2_pem }); + try env.store.reload(io); + + const new_entry = env.store.acquire(io); + defer env.store.release(io, new_entry); + try testing.expect(old_entry != new_entry); + + // A handshake after the reload succeeds, served by the new generation. + var second: TestTls = undefined; + try second.connect(io, address_); + defer second.close(io); + try second.sendQuery(); + try expectAnswersQuery(try second.readReply()); + + // The established connection still answers on the retired generation. + try first.sendQuery(); + try expectAnswersQuery(try first.readReply()); + + try second.client.end(); + try second.net_writer.interface.flush(); + var second_tail: [1]u8 = undefined; + try testing.expectError(error.EndOfStream, second.client.reader.readSliceAll(&second_tail)); + + try first.client.end(); + try first.net_writer.interface.flush(); + var first_tail: [1]u8 = undefined; + try testing.expectError(error.EndOfStream, first.client.reader.readSliceAll(&first_tail)); +} + +test "dot: a reload serves new handshakes without breaking the old connection" { + const build_options = @import("build_options"); + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var env: CertEnv = undefined; + try env.init(io); + // `CertStore.deinit` inside asserts refs == 0: both connections released + // their generations before the clients saw close_notify. + defer env.deinit(io); + + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h = bareHandler(fake.client()); + + const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0); + var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 }); + const server_address = server.boundAddress(); + + var group: std.Io.Group = .init; + try group.concurrent(io, DotServer.serve, .{ &server, io }); + + try bounded(io, dotReloadKeepsOldConnAndServesNew, .{ io, server_address, &env }); + + const stats = server.snapshotStats(); + try testing.expectEqual(@as(u64, 2), stats.connections); + try testing.expectEqual(@as(u64, 0), stats.tls_handshake_failures); + try testing.expectEqual(@as(u64, 0), stats.idle_timeouts); + try testing.expectEqual(@as(u64, 0), stats.connection_errors); + try testing.expectEqual(@as(u64, 3), h.stats.queries.load(.monotonic)); + + const store_stats = env.store.snapshotStats(); + try testing.expectEqual(@as(u64, 1), store_stats.reloads); + try testing.expectEqual(@as(u64, 0), store_stats.reload_failures); + + server.deinit(io); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; +} + +test "dot: an idle connection is closed with close_notify and counted" { + const build_options = @import("build_options"); + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var env: CertEnv = undefined; + try env.init(io); + defer env.deinit(io); + + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h = bareHandler(fake.client()); + + const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0); + var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ + .max_connections = 2, + .idle_timeout = short_idle, + }); + const server_address = server.boundAddress(); + + var group: std.Io.Group = .init; + try group.concurrent(io, DotServer.serve, .{ &server, io }); + + try bounded(io, dotIdleUntilServerCloses, .{ io, server_address }); + + const stats = server.snapshotStats(); + try testing.expectEqual(@as(u64, 1), stats.connections); + try testing.expectEqual(@as(u64, 1), stats.idle_timeouts); + try testing.expectEqual(@as(u64, 0), stats.tls_handshake_failures); + try testing.expectEqual(@as(u64, 0), stats.connection_errors); + + server.deinit(io); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; +} diff --git a/src/tests.zig b/src/tests.zig index 0e28b32..751334c 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -108,6 +108,10 @@ comptime { _ = @import("web/routes.zig"); _ = @import("web/handlers/live.zig"); _ = @import("web/web_integration_test.zig"); + _ = @import("server/cert_store.zig"); + _ = @import("server/dot_server.zig"); + _ = @import("server/doh_server.zig"); + _ = @import("web/handlers/certs.zig"); } extern fn sqlite3_libversion() [*:0]const u8; diff --git a/src/web/handlers/certs.zig b/src/web/handlers/certs.zig new file mode 100644 index 0000000..664295b --- /dev/null +++ b/src/web/handlers/certs.zig @@ -0,0 +1,136 @@ +//! `POST /api/certs/reload` — reload the DoH/DoT certificates from disk +//! (milestone-10 ruling 8). +//! +//! Always answers 200: the per-endpoint outcome IS the payload. A failed +//! reload is an outcome, not a server error — the store publishes nothing on +//! failure (PLAN:297), so the old certificate keeps serving and nothing about +//! the web server's own state is exceptional. A disabled endpoint has no +//! store and reports `{enabled: false, reloaded: false, error: null}`. + +const std = @import("std"); + +const cert_store = @import("../../server/cert_store.zig"); +const http_util = @import("../http_util.zig"); +const server = @import("../server.zig"); + +const Request = http_util.Request; +const HandlerError = http_util.HandlerError; + +pub const Outcome = struct { + enabled: bool, + reloaded: bool, + /// `cert_store.humanMessage` text; null on success and while disabled. + @"error": ?[]const u8, +}; + +pub const View = struct { + doh: Outcome, + dot: Outcome, +}; + +/// Reloads every enabled endpoint's store. A null store is a disabled +/// endpoint: the composition root only constructs one for an enabled +/// endpoint (milestone-10 ruling 11). +pub fn applyReload(state: *server.WebState, io: std.Io) View { + return .{ + .doh = outcome(state.doh_certs, io), + .dot = outcome(state.dot_certs, io), + }; +} + +fn outcome(store: ?*cert_store.CertStore, io: std.Io) Outcome { + const live = store orelse return .{ .enabled = false, .reloaded = false, .@"error" = null }; + live.reload(io) catch |err| return .{ + .enabled = true, + .reloaded = false, + .@"error" = cert_store.humanMessage(err), + }; + return .{ .enabled = true, .reloaded = true, .@"error" = null }; +} + +pub fn post(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + return http_util.respondJson(request, .ok, applyReload(state, io), &.{}); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const fixtures = @import("test_fixtures"); +const testing = std.testing; + +test "both endpoints disabled report exactly the disabled outcome" { + var state: server.WebState = .{ .gpa = testing.allocator }; + const view = applyReload(&state, undefined); + for ([_]Outcome{ view.doh, view.dot }) |per_endpoint| { + try testing.expect(!per_endpoint.enabled); + try testing.expect(!per_endpoint.reloaded); + try testing.expectEqual(@as(?[]const u8, null), per_endpoint.@"error"); + } +} + +/// Fixture PEMs in a tmp directory, addressed the way `app.zig` hands config +/// paths to the store. Must not move after `init`: the path slices point into +/// the buffers below. +const TestEnv = struct { + threaded: std.Io.Threaded, + tmp: testing.TmpDir, + cert_path_buf: [128]u8, + key_path_buf: [128]u8, + cert_path: []const u8, + key_path: []const u8, + + fn init(env: *TestEnv) !void { + env.threaded = .init(testing.allocator, .{}); + errdefer env.threaded.deinit(); + env.tmp = testing.tmpDir(.{}); + errdefer env.tmp.cleanup(); + + const env_io = env.threaded.io(); + try env.tmp.dir.writeFile(env_io, .{ .sub_path = "cert.pem", .data = fixtures.cert_pem }); + try env.tmp.dir.writeFile(env_io, .{ .sub_path = "key.pem", .data = fixtures.key_pem }); + env.cert_path = try std.fmt.bufPrint(&env.cert_path_buf, ".zig-cache/tmp/{s}/cert.pem", .{env.tmp.sub_path}); + env.key_path = try std.fmt.bufPrint(&env.key_path_buf, ".zig-cache/tmp/{s}/key.pem", .{env.tmp.sub_path}); + } + + fn deinit(env: *TestEnv) void { + env.tmp.cleanup(); + env.threaded.deinit(); + } + + fn io(env: *TestEnv) std.Io { + return env.threaded.io(); + } +}; + +test "a wired store reloads; a broken one reports the message in the same payload" { + var env: TestEnv = undefined; + try env.init(); + defer env.deinit(); + const io = env.io(); + + var store = try cert_store.CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); + defer store.deinit(io); + + var state: server.WebState = .{ .gpa = testing.allocator, .doh_certs = &store }; + + const succeeded = applyReload(&state, io); + try testing.expect(succeeded.doh.enabled); + try testing.expect(succeeded.doh.reloaded); + try testing.expectEqual(@as(?[]const u8, null), succeeded.doh.@"error"); + try testing.expect(!succeeded.dot.enabled); + try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads); + + // The certificate file vanishes: the outcome names the failure, the store + // keeps its old generation, and the dot half is untouched. + try env.tmp.dir.deleteFile(io, "cert.pem"); + const failed = applyReload(&state, io); + try testing.expect(failed.doh.enabled); + try testing.expect(!failed.doh.reloaded); + try testing.expectEqualStrings( + cert_store.humanMessage(error.CertUnreadable), + failed.doh.@"error".?, + ); + try testing.expect(!failed.dot.enabled); + try testing.expectEqual(@as(u64, 1), store.snapshotStats().reload_failures); +} diff --git a/src/web/metrics.zig b/src/web/metrics.zig index 49d36e6..34a8e70 100644 --- a/src/web/metrics.zig +++ b/src/web/metrics.zig @@ -21,10 +21,12 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +const cert_store = @import("../server/cert_store.zig"); const clients = @import("../server/clients.zig"); const dns_cache = @import("../cache/dns_cache.zig"); const dns_handler = @import("../server/handler.zig"); const disk_monitor = @import("../storage/disk_monitor.zig"); +const dot_server = @import("../server/dot_server.zig"); const http_util = @import("http_util.zig"); const logging = @import("../platform/logging.zig"); const pool_mod = @import("../upstream/pool.zig"); @@ -80,6 +82,18 @@ pub const DiskSample = struct { sample_failures: u64, }; +/// The DoH listener counters this exposition exports (milestone-10 ruling 10): +/// the four every TLS listener keeps, plus DoH's `bad_requests`. A subset of +/// `doh_server.Snapshot` on purpose — the accept-side refusal counters stay +/// internal, exactly as they do for the DoT listener and TCP/53. +pub const DohListenerSample = struct { + connections: u64, + tls_handshake_failures: u64, + idle_timeouts: u64, + connection_errors: u64, + bad_requests: u64, +}; + /// One upstream, with every string owned by the caller's arena. pub const UpstreamSample = struct { url: []const u8, @@ -103,6 +117,16 @@ pub const Sample = struct { retention: ?retention_mod.Stats = null, blocklist: ?BlocklistSample = null, disk: ?DiskSample = null, + /// One entry per enabled TLS endpoint (milestone-10 ruling 10). Rendered + /// under an `endpoint` label so both share the two `nxdns_cert_*` + /// families. `last_reload_unix` is deliberately not exported: the reload + /// endpoint reports cert state on demand. + doh_certs: ?cert_store.CertStore.Stats = null, + dot_certs: ?cert_store.CertStore.Stats = null, + /// The listener families (ruling 10): absent while an endpoint is + /// disabled or its bind failed, like every other unwired collaborator. + doh_listener: ?DohListenerSample = null, + dot_listener: ?dot_server.StatsSnapshot = null, upstreams: []const UpstreamSample = &.{}, }; @@ -169,6 +193,21 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator. .sample_failures = monitor.sample_failures.load(.monotonic), }; + if (state.doh_certs) |store| sample.doh_certs = store.snapshotStats(); + if (state.dot_certs) |store| sample.dot_certs = store.snapshotStats(); + + if (state.doh_listener) |listener| { + const snapshot = listener.snapshotStats(); + sample.doh_listener = .{ + .connections = snapshot.connections, + .tls_handshake_failures = snapshot.tls_handshake_failures, + .idle_timeouts = snapshot.idle_timeouts, + .connection_errors = snapshot.connection_errors, + .bad_requests = snapshot.bad_requests, + }; + } + if (state.dot_listener) |listener| sample.dot_listener = listener.snapshotStats(); + if (state.pool) |pool| sample.upstreams = try upstreams(pool, io, arena); return sample; @@ -294,9 +333,48 @@ pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void { ); } + if (sample.doh_listener) |listener| { + try counterGroup(w, "nxdns_doh_server_", "DoH listener counter", listener); + } + if (sample.dot_listener) |listener| { + try counterGroup(w, "nxdns_dot_server_", "DoT listener counter", listener); + } + + if (sample.doh_certs != null or sample.dot_certs != null) try renderCerts(w, sample); + if (sample.upstreams.len != 0) try renderUpstreams(w, sample.upstreams); } +fn renderCerts(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void { + try labeledHead(w, "nxdns_cert_reloads_total", "Certificate reloads that published a new context.", "counter"); + if (sample.doh_certs) |stats| try endpointValue(w, "nxdns_cert_reloads_total", "doh", stats.reloads); + if (sample.dot_certs) |stats| try endpointValue(w, "nxdns_cert_reloads_total", "dot", stats.reloads); + + try labeledHead( + w, + "nxdns_cert_reload_failures_total", + "Certificate reloads that failed; the old certificate keeps serving.", + "counter", + ); + if (sample.doh_certs) |stats| { + try endpointValue(w, "nxdns_cert_reload_failures_total", "doh", stats.reload_failures); + } + if (sample.dot_certs) |stats| { + try endpointValue(w, "nxdns_cert_reload_failures_total", "dot", stats.reload_failures); + } +} + +/// The endpoint names are ours ("doh"/"dot"), so unlike a url label there is +/// nothing to escape. +fn endpointValue( + w: *std.Io.Writer, + name: []const u8, + endpoint: []const u8, + value: u64, +) std.Io.Writer.Error!void { + try w.print("{s}{{endpoint=\"{s}\"}} {d}\n", .{ name, endpoint, value }); +} + fn renderUpstreams(w: *std.Io.Writer, list: []const UpstreamSample) std.Io.Writer.Error!void { try labeledHead(w, "nxdns_upstream_up", "1 while an upstream is enabled and healthy.", "gauge"); for (list) |entry| try labeledValue(w, "nxdns_upstream_up", entry.url, @intFromBool(entry.available)); @@ -528,6 +606,69 @@ test "an unwired collaborator omits its family rather than reporting zeros" { try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_disk_")); try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_upstream_")); try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_blocklist_")); + try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_cert_")); + try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_doh_server_")); + try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_dot_server_")); +} + +test "listener counters render only for the wired servers" { + const doh_only = try renderToString(testing.allocator, .{ + .doh_listener = .{ + .connections = 9, + .tls_handshake_failures = 2, + .idle_timeouts = 1, + .connection_errors = 0, + .bad_requests = 4, + }, + }); + defer testing.allocator.free(doh_only); + + try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_connections_total 9\n")); + try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_tls_handshake_failures_total 2\n")); + try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_idle_timeouts_total 1\n")); + try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_connection_errors_total 0\n")); + try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_bad_requests_total 4\n")); + try testing.expect(!std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_dot_server_")); + + const dot_only = try renderToString(testing.allocator, .{ + .dot_listener = .{ + .connections = 5, + .tls_handshake_failures = 0, + .idle_timeouts = 3, + .connection_errors = 1, + }, + }); + defer testing.allocator.free(dot_only); + + try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_connections_total 5\n")); + try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_idle_timeouts_total 3\n")); + try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_connection_errors_total 1\n")); + // The DoT listener has no HTTP layer, so no bad_requests family. + try testing.expect(!std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_bad_requests_total")); + try testing.expect(!std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_doh_server_")); +} + +test "cert reload counters render per endpoint, only for the wired stores" { + const one = try renderToString(testing.allocator, .{ + .doh_certs = .{ .reloads = 2, .reload_failures = 1, .last_reload_unix = 1_700_000_000 }, + }); + defer testing.allocator.free(one); + + try testing.expect(std.mem.containsAtLeast(u8, one, 1, "nxdns_cert_reloads_total{endpoint=\"doh\"} 2\n")); + try testing.expect(std.mem.containsAtLeast(u8, one, 1, "nxdns_cert_reload_failures_total{endpoint=\"doh\"} 1\n")); + try testing.expect(!std.mem.containsAtLeast(u8, one, 1, "endpoint=\"dot\"")); + // The wall-clock second stays off the exposition. + try testing.expect(!std.mem.containsAtLeast(u8, one, 1, "last_reload")); + + const both = try renderToString(testing.allocator, .{ + .doh_certs = .{ .reloads = 0, .reload_failures = 0, .last_reload_unix = 0 }, + .dot_certs = .{ .reloads = 3, .reload_failures = 0, .last_reload_unix = 0 }, + }); + defer testing.allocator.free(both); + + try testing.expect(std.mem.containsAtLeast(u8, both, 1, "nxdns_cert_reloads_total{endpoint=\"doh\"} 0\n")); + try testing.expect(std.mem.containsAtLeast(u8, both, 1, "nxdns_cert_reloads_total{endpoint=\"dot\"} 3\n")); + try testing.expect(std.mem.containsAtLeast(u8, both, 1, "nxdns_cert_reload_failures_total{endpoint=\"dot\"} 0\n")); } test "a label value escapes the characters the format reserves" { diff --git a/src/web/openapi.yaml b/src/web/openapi.yaml index 4a45eaf..e33cf8a 100644 --- a/src/web/openapi.yaml +++ b/src/web/openapi.yaml @@ -1463,6 +1463,25 @@ paths: "503": $ref: "#/components/responses/Unavailable" + /api/certs/reload: + post: + summary: Reload the TLS certificates from disk + description: | + Reloads the certificate and key of every enabled DoH/DoT endpoint. + Always answers 200: the per-endpoint outcome is the payload, and a + failed reload leaves the previous certificate serving. + responses: + "200": + description: The outcome for each endpoint. + content: + application/json: + schema: + $ref: "#/components/schemas/CertsReload" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + components: securitySchemes: sessionCookie: @@ -2044,6 +2063,29 @@ components: maximum: 604800 description: Only meaningful with `paused = true`; absent means indefinite. + CertReloadOutcome: + type: object + required: [enabled, reloaded, error] + properties: + enabled: + type: boolean + description: Whether the endpoint is enabled in the configuration. + reloaded: + type: boolean + error: + type: string + nullable: true + description: Why the reload failed; null on success and while disabled. + + CertsReload: + type: object + required: [doh, dot] + properties: + doh: + $ref: "#/components/schemas/CertReloadOutcome" + dot: + $ref: "#/components/schemas/CertReloadOutcome" + Settings: type: object required: [runtime, upstream, dns, blocking, cache, web, doh_server, dot_server, edns, logging, disk, blocklist_update] diff --git a/src/web/openapi.zig b/src/web/openapi.zig index 7398569..4697351 100644 --- a/src/web/openapi.zig +++ b/src/web/openapi.zig @@ -48,11 +48,6 @@ test "every served route appears textually in the document" { } } -test "the document does not promise what phase 9 owns" { - // Ruling 2: certs/reload lands with the DoH/DoT server, whole. - try testing.expect(!std.mem.containsAtLeast(u8, yaml, 1, "certs/reload")); -} - test "the document names the contract's fixed points" { for ([_][]const u8{ "openapi: 3.0.3", diff --git a/src/web/routes.zig b/src/web/routes.zig index 6ab5175..b905a64 100644 --- a/src/web/routes.zig +++ b/src/web/routes.zig @@ -23,6 +23,7 @@ const router = @import("router.zig"); const auth = @import("handlers/auth.zig"); const blocklists = @import("handlers/blocklists.zig"); +const certs = @import("handlers/certs.zig"); const clients = @import("handlers/clients.zig"); const groups = @import("handlers/groups.zig"); const health = @import("handlers/health.zig"); @@ -118,6 +119,9 @@ pub const table: []const router.RouteInfo = &.{ .{ .method = .POST, .pattern = "/api/pause", .auth = .session, .handler = pause.post }, .{ .method = .GET, .pattern = "/api/settings", .auth = .session, .handler = settings.get }, .{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .handler = settings.put }, + + // Certificates (milestone-10 ruling 8). + .{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .handler = certs.post }, }; // --------------------------------------------------------------------------- @@ -128,7 +132,7 @@ const std = @import("std"); const testing = std.testing; test "the table carries every endpoint of the milestone" { - try testing.expectEqual(@as(usize, 55), table.len); + try testing.expectEqual(@as(usize, 56), table.len); } test "no two entries claim the same method and pattern" { diff --git a/src/web/server.zig b/src/web/server.zig index b39dfc1..12ffee1 100644 --- a/src/web/server.zig +++ b/src/web/server.zig @@ -33,10 +33,13 @@ const Allocator = std.mem.Allocator; const address = @import("../platform/address.zig"); const api_limiter = @import("api_limiter.zig"); const auth = @import("auth.zig"); +const cert_store = @import("../server/cert_store.zig"); const clients = @import("../server/clients.zig"); const db = @import("../storage/db.zig"); const disk_monitor = @import("../storage/disk_monitor.zig"); const dns_handler = @import("../server/handler.zig"); +const doh_server = @import("../server/doh_server.zig"); +const dot_server = @import("../server/dot_server.zig"); const http_util = @import("http_util.zig"); const local_tables_mod = @import("../server/local_tables.zig"); const logger_mod = @import("../storage/logger.zig"); @@ -138,6 +141,16 @@ pub const WebState = struct { /// live-query handler subscribes. hub: ?*sse.Hub = null, sink: ?*query_sink.QuerySink = null, + /// The DoH/DoT certificate stores; null while an endpoint is disabled. + /// `POST /api/certs/reload` reloads through these, and `/metrics` reads + /// their counters (milestone-10 rulings 8 and 10). + doh_certs: ?*cert_store.CertStore = null, + dot_certs: ?*cert_store.CertStore = null, + /// The DoH/DoT listeners themselves; null while an endpoint is disabled + /// or its bind failed. `/metrics` reads their connection counters + /// (milestone-10 ruling 10). + doh_listener: ?*doh_server.DohServer = null, + dot_listener: ?*dot_server.DotServer = null, /// The web task's own connections (m7 ruling 21) — never the DNS path's. config_db: ?*db.Db = null, diff --git a/src/web/web_integration_test.zig b/src/web/web_integration_test.zig index 6beec9e..5c7eab7 100644 --- a/src/web/web_integration_test.zig +++ b/src/web/web_integration_test.zig @@ -56,6 +56,7 @@ const types = @import("../dns/types.zig"); const upstreams_repo = @import("../storage/repositories/upstreams_repo.zig"); const handlers_blocklists = @import("handlers/blocklists.zig"); +const handlers_certs = @import("handlers/certs.zig"); const handlers_health = @import("handlers/health.zig"); const handlers_live = @import("handlers/live.zig"); const handlers_lookup = @import("handlers/lookup.zig"); @@ -670,6 +671,11 @@ const contract = [_]Contract{ .{ .method = .GET, .pattern = "/api/settings", .auth = .session, .target = "/api/settings", .status = 200, .check = jsonShape(SettingsView) }, .{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200, .check = jsonShape(SettingsView) }, + // Certificates. The walk's environment wires no cert store, so both + // endpoints report disabled — and the reload still answers 200 (m10 + // ruling 8: the outcome is the payload). + .{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .target = "/api/certs/reload", .status = 200, .check = jsonShape(handlers_certs.View) }, + // The walk's last delete returns the groups table to its seeded shape. .{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .status = 204, .kind = .none }, }; @@ -773,6 +779,36 @@ test "W10 contract: every route answers its documented status and shape" { try bounded(env.io(), default_budget, contractWalk, .{ env.io(), env }); } +// The wire shape of the certs reload payload, restated so the handler's own +// `View` cannot vouch for itself. Runs in every suite: it needs no socket. +const CertOutcomeShape = struct { enabled: bool, reloaded: bool, @"error": ?[]const u8 }; +const CertsReloadShape = struct { doh: CertOutcomeShape, dot: CertOutcomeShape }; + +test "the certs reload payload with both endpoints disabled parses strictly" { + const gpa = testing.allocator; + + var state: server.WebState = .{ .gpa = gpa }; + const view = handlers_certs.applyReload(&state, undefined); + + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + try std.json.Stringify.value(view, .{}, &out.writer); + + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const parsed = try std.json.parseFromSliceLeaky( + CertsReloadShape, + arena_state.allocator(), + out.written(), + .{ .ignore_unknown_fields = false }, + ); + for ([_]CertOutcomeShape{ parsed.doh, parsed.dot }) |per_endpoint| { + try testing.expect(!per_endpoint.enabled); + try testing.expect(!per_endpoint.reloaded); + try testing.expectEqual(@as(?[]const u8, null), per_endpoint.@"error"); + } +} + // --------------------------------------------------------------------------- // auth on/off matrix (rulings 17, 18) // --------------------------------------------------------------------------- diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index b840744..03cf56a 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -1,8 +1,9 @@ # Test fixtures -`self_signed_cert.pem` and `self_signed_key.pem` are a **test fixture**. The -private key is **intentionally committed** to this public repository. It is not -a secret and it must never protect anything real. +`self_signed_cert.pem`/`self_signed_key.pem` and +`self_signed_cert2.pem`/`self_signed_key2.pem` are **test fixtures**. The +private keys are **intentionally committed** to this public repository. They +are not secrets and must never protect anything real. Properties: @@ -11,10 +12,12 @@ Properties: - SAN `DNS:localhost`, `IP:127.0.0.1` - Validity 36500 days from generation -The loopback TLS test in `src/platform/tls_server.zig` uses this pair. Nothing -in the shipped binary reads it. +The loopback TLS test in `src/platform/tls_server.zig` uses the first pair. +The second pair exists so certificate-reload tests can swap between two valid +identities (milestone-10 ruling 13). Nothing in the shipped binary reads +either. -Regenerate with: +Regenerate with (substitute `2` in both file names for the second pair): ```sh openssl ecparam -name prime256v1 -genkey -noout -out self_signed_key.pem diff --git a/tests/fixtures/fixtures.zig b/tests/fixtures/fixtures.zig index 1beb247..56e74cd 100644 --- a/tests/fixtures/fixtures.zig +++ b/tests/fixtures/fixtures.zig @@ -7,3 +7,8 @@ pub const key_pem: [:0]const u8 = @embedFile("self_signed_key.pem"); /// A well-formed private key that does not belong to `cert_pem`. pub const mismatched_key_pem: [:0]const u8 = @embedFile("mismatched_key.pem"); + +/// A second, distinct self-signed pair (same profile as the first) so +/// certificate-reload tests can swap between two valid identities. +pub const cert2_pem: [:0]const u8 = @embedFile("self_signed_cert2.pem"); +pub const key2_pem: [:0]const u8 = @embedFile("self_signed_key2.pem"); diff --git a/tests/fixtures/self_signed_cert2.pem b/tests/fixtures/self_signed_cert2.pem new file mode 100644 index 0000000..7e17f0e --- /dev/null +++ b/tests/fixtures/self_signed_cert2.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBmzCCAUGgAwIBAgIUQVvG0NCXCIgdQCFyQuJSfoFakSowCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDgwMjExNDUzNVoYDzIxMjYwNzA5 +MTE0NTM1WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjO +PQMBBwNCAATRtQJUpAMiTlq/p7iVf9i3OimwlQWDfQF+5JukzaXEcUmdgWbEFwMu +VvOj7QN3FIbBpSmksjtUpciK5CAn/tjoo28wbTAdBgNVHQ4EFgQUlZqpXrKmHgPn +k9j8CxQ82XlbKQUwHwYDVR0jBBgwFoAUlZqpXrKmHgPnk9j8CxQ82XlbKQUwDwYD +VR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8AAAEwCgYIKoZI +zj0EAwIDSAAwRQIgLzOraDY3rbREZyvuEeuJgycajvL+0xQPYcF9Ukki3t0CIQCI +YGtP3cBvclkBk1ASnFIO36HHLNtdgTnBRq4X0HkVKA== +-----END CERTIFICATE----- diff --git a/tests/fixtures/self_signed_key2.pem b/tests/fixtures/self_signed_key2.pem new file mode 100644 index 0000000..2ceffa6 --- /dev/null +++ b/tests/fixtures/self_signed_key2.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIDDLqib/NiyAJF4nBScYQf1lyAb4aAJmIbQqufbiKib7oAoGCCqGSM49 +AwEHoUQDQgAE0bUCVKQDIk5av6e4lX/YtzopsJUFg30BfuSbpM2lxHFJnYFmxBcD +Llbzo+0DdxSGwaUppLI7VKXIiuQgJ/7Y6A== +-----END EC PRIVATE KEY-----