milestone 10: doh and dot listeners, cert store with hot reload and cert reload api
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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=<base64url unpadded>`. 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": <msg>|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.
|
||||
+128
-1
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
+5
-1
@@ -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" {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Vendored
+9
-6
@@ -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
|
||||
|
||||
Vendored
+5
@@ -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");
|
||||
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBmzCCAUGgAwIBAgIUQVvG0NCXCIgdQCFyQuJSfoFakSowCgYIKoZIzj0EAwIw
|
||||
FDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDgwMjExNDUzNVoYDzIxMjYwNzA5
|
||||
MTE0NTM1WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjO
|
||||
PQMBBwNCAATRtQJUpAMiTlq/p7iVf9i3OimwlQWDfQF+5JukzaXEcUmdgWbEFwMu
|
||||
VvOj7QN3FIbBpSmksjtUpciK5CAn/tjoo28wbTAdBgNVHQ4EFgQUlZqpXrKmHgPn
|
||||
k9j8CxQ82XlbKQUwHwYDVR0jBBgwFoAUlZqpXrKmHgPnk9j8CxQ82XlbKQUwDwYD
|
||||
VR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8AAAEwCgYIKoZI
|
||||
zj0EAwIDSAAwRQIgLzOraDY3rbREZyvuEeuJgycajvL+0xQPYcF9Ukki3t0CIQCI
|
||||
YGtP3cBvclkBk1ASnFIO36HHLNtdgTnBRq4X0HkVKA==
|
||||
-----END CERTIFICATE-----
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIDDLqib/NiyAJF4nBScYQf1lyAb4aAJmIbQqufbiKib7oAoGCCqGSM49
|
||||
AwEHoUQDQgAE0bUCVKQDIk5av6e4lX/YtzopsJUFg30BfuSbpM2lxHFJnYFmxBcD
|
||||
Llbzo+0DdxSGwaUppLI7VKXIiuQgJ/7Y6A==
|
||||
-----END EC PRIVATE KEY-----
|
||||
Reference in New Issue
Block a user