milestone 19: hygiene sweep - dead ecs surface, single-source constants, tls classification, frontend state hazards, docker smoke network fix
CI / test (push) Successful in 1m46s
CI / test-aarch64 (push) Successful in 5m30s
CI / frontend (push) Successful in 46s
CI / cross (push) Successful in 8m12s
CI / docker (push) Successful in 3m46s

This commit is contained in:
2026-08-07 20:39:27 +02:00
parent 6f67940995
commit 6c507992e4
59 changed files with 1020 additions and 382 deletions
+16 -6
View File
@@ -266,16 +266,25 @@ jobs:
# socket, so a -v path would resolve on the docker host (where the # socket, so a -v path would resolve on the docker host (where the
# workspace does not exist) and mount an empty directory over # workspace does not exist) and mount an empty directory over
# /etc/nxdns. docker cp streams the file through the socket instead. # /etc/nxdns. docker cp streams the file through the socket instead.
#
# Networking: this job itself runs in a container on the runner's
# per-job network. A published port binds on the daemon's host, not
# here, and docker does not route between the default bridge and
# that network — a bridge-IP curl hangs to its connect timeout. So
# the smoke container joins the job's own network, where its name
# resolves and its port is reachable. On a host runner the inspect
# finds no container and the published-port path covers it.
net=$(docker inspect "$(hostname)" \
-f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}' \
2>/dev/null || true)
cid=$(docker create --name nxdns-smoke \ cid=$(docker create --name nxdns-smoke \
-p 127.0.0.1:8080:8080 \ ${net:+--network "$net"} \
-p 127.0.0.1:18080:8080 \
nxdns:ci) nxdns:ci)
trap 'docker rm -f nxdns-smoke >/dev/null 2>&1 || true' EXIT trap 'docker rm -f nxdns-smoke >/dev/null 2>&1 || true' EXIT
docker cp etc-nxdns/config.zon nxdns-smoke:/etc/nxdns/config.zon docker cp etc-nxdns/config.zon nxdns-smoke:/etc/nxdns/config.zon
docker start nxdns-smoke docker start nxdns-smoke
# The published port works when the job runs on the docker host or in
# DinD; the container IP covers a runner that shares the daemon over
# a mounted socket.
ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$cid") ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$cid")
healthy="" healthy=""
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
@@ -284,8 +293,9 @@ jobs:
docker logs "$cid" || true docker logs "$cid" || true
exit 1 exit 1
fi fi
if curl -fsS "http://127.0.0.1:8080/api/health" \ if curl -fsS --connect-timeout 2 "http://nxdns-smoke:8080/api/health" \
|| { [ -n "$ip" ] && curl -fsS "http://$ip:8080/api/health"; }; then || curl -fsS --connect-timeout 2 "http://127.0.0.1:18080/api/health" \
|| { [ -n "$ip" ] && curl -fsS --connect-timeout 2 "http://$ip:8080/api/health"; }; then
healthy=1 healthy=1
break break
fi fi
+20 -20
View File
@@ -119,19 +119,19 @@ The repo's history proves this theme's stakes: commit 35f2324 fixed a process-ki
- **[low — CLOSED m16] src/filter/manager.zig:1237 — commitStatus silently drops the outcome of a source with no status entry.** - **[low — CLOSED m16] src/filter/manager.zig:1237 — commitStatus silently drops the outcome of a source with no status entry.**
Reachable today via a race between startupPass and a web-inserted source; the milestone-5 policy says every non-ok state "is recorded and surfaced, not swallowed". Fix: warn on the fall-through or have refreshSourceLocked insert the missing entry. Reachable today via a race between startupPass and a web-inserted source; the milestone-5 policy says every non-ok state "is recorded and surfaced, not swallowed". Fix: warn on the fall-through or have refreshSourceLocked insert the missing entry.
- **[low] src/web/http_util.zig:335 — static header-array overflow reported as OutOfMemory.** - **[low — CLOSED m19] src/web/http_util.zig:335 — static header-array overflow reported as OutOfMemory.**
A future eighth header would manifest as mysterious OOM-labelled connection drops. All call sites are static (max 3 today). Fix: std.debug.assert so the mistake fails loudly in tests. A future eighth header would manifest as mysterious OOM-labelled connection drops. All call sites are static (max 3 today). Fix: std.debug.assert so the mistake fails loudly in tests.
- **[low] web/src/features/blocklists/BlocklistForm.tsx:30 — bare `catch {}` on submit swallows everything, not just the mutation error it assumes.** - **[low — CLOSED m19] web/src/features/blocklists/BlocklistForm.tsx:30 — bare `catch {}` on submit swallows everything, not just the mutation error it assumes.**
An exception outside mutateAsync never reaches mutation state, so the form silently does nothing with no console trace. The codebase's own convention (milestone-9 review: logout swallows only 401) is swallow-only-the-expected-class. Fix: rethrow or console.error anything the inline-error prop will not display. An exception outside mutateAsync never reaches mutation state, so the form silently does nothing with no console trace. The codebase's own convention (milestone-9 review: logout swallows only 401) is swallow-only-the-expected-class. Fix: rethrow or console.error anything the inline-error prop will not display.
- **[low] tools/gen_web_assets.zig:71 — dist-shipped .gz siblings trusted unverified; orphan .gz files embed as unreachable bytes.** - **[low — CLOSED m19] tools/gen_web_assets.zig:71 — dist-shipped .gz siblings trusted unverified; orphan .gz files embed as unreachable bytes.**
The embedded-dist test partially covers this but never gunzips-and-compares, and CI's test run never sees the dist that ships (`cross -Dweb-dist` vs `test -Dintegration`). Exposure is latent (no compression plugin today). Fix: decompress and byte-compare shipped siblings; reject orphans. The embedded-dist test partially covers this but never gunzips-and-compares, and CI's test run never sees the dist that ships (`cross -Dweb-dist` vs `test -Dintegration`). Exposure is latent (no compression plugin today). Fix: decompress and byte-compare shipped siblings; reject orphans.
- **[low] deploy/docker/Dockerfile:20 — `${TARGETARCH:-amd64}` bypasses the fail-fast arm under the legacy builder.** - **[low — CLOSED m19] deploy/docker/Dockerfile:20 — `${TARGETARCH:-amd64}` bypasses the fail-fast arm under the legacy builder.**
A plain docker build on an arm64 host (the Pi 5 target) packages the x86_64 binary and dies at `docker run` with exec-format, far from the mistake. Fix: drop the default and error naming buildx, or default from `uname -m`. A plain docker build on an arm64 host (the Pi 5 target) packages the x86_64 binary and dies at `docker run` with exec-format, far from the mistake. Fix: drop the default and error naming buildx, or default from `uname -m`.
- **[low] src/platform/logging.zig:287 — oversized log messages truncate mid-format with no marker.** - **[low — CLOSED m19] src/platform/logging.zig:287 — oversized log messages truncate mid-format with no marker.**
`mw.print(...) catch {}` then uses the partial buffer, contradicting the sink's own "never both discarded and silent" invariant and the '...'-marker pattern safe_url.zig uses. Fix: catch the fixed-writer overflow and append a marker or bump a lines_truncated stat. `mw.print(...) catch {}` then uses the partial buffer, contradicting the sink's own "never both discarded and silent" invariant and the '...'-marker pattern safe_url.zig uses. Fix: catch the fixed-writer overflow and append a marker or bump a lines_truncated stat.
## Theme 4: Operator-facing contract drift (11 findings) ## Theme 4: Operator-facing contract drift (11 findings)
@@ -177,10 +177,10 @@ The repo's history proves this theme's stakes: commit 35f2324 fixed a process-ki
- **[medium — CLOSED m16] web/src/features/live/useLiveQueries.ts:29 — SSE cap/session-expiry detection relies on EventSource retry behavior that does not exist for HTTP rejections.** - **[medium — CLOSED m16] web/src/features/live/useLiveQueries.ts:29 — SSE cap/session-expiry detection relies on EventSource retry behavior that does not exist for HTTP rejections.**
Per the WHATWG spec a non-200 response fails the connection permanently after one error event, so the 3-consecutive-errors threshold is unreachable on exactly the 429/401 paths it was built for: the UI shows "Reconnecting…" forever, the capped state and session probe are dead, and the spec's required "too many live viewers + retry" state is violated. FakeEventSource has no readyState/reconnect semantics, so tests pass — the mocked-network class again. Fix: expose readyState on EventSourceLike, treat CLOSED in the error handler as permanent failure, and run the session probe there. Per the WHATWG spec a non-200 response fails the connection permanently after one error event, so the 3-consecutive-errors threshold is unreachable on exactly the 429/401 paths it was built for: the UI shows "Reconnecting…" forever, the capped state and session probe are dead, and the spec's required "too many live viewers + retry" state is violated. FakeEventSource has no readyState/reconnect semantics, so tests pass — the mocked-network class again. Fix: expose readyState on EventSourceLike, treat CLOSED in the error handler as permanent failure, and run the session probe there.
- **[low] src/filter/fetcher.zig:169 — TLS error classification by @errorName prefix string-matching.** - **[low — CLOSED m19] src/filter/fetcher.zig:169 — TLS error classification by @errorName prefix string-matching.**
Duplicated in doh_client.zig; a std rename silently downgrades TLS failures into generic buckets, and the test exercises inputs std can no longer produce (verification shows only TlsInitializationFailed and CertificateBundleLoadFailure are reachable). Contradicts transport.zig's own exhaustive-switch philosophy. Fix: name the reachable errors in the exact-match switch in both files. Duplicated in doh_client.zig; a std rename silently downgrades TLS failures into generic buckets, and the test exercises inputs std can no longer produce (verification shows only TlsInitializationFailed and CertificateBundleLoadFailure are reachable). Contradicts transport.zig's own exhaustive-switch philosophy. Fix: name the reachable errors in the exact-match switch in both files.
- **[low] src/platform/tls_server.zig:506 — hand-transcribed mbedtls error codes/config enums have no drift guard, unlike sizes/alignment.** - **[low — CLOSED m19] src/platform/tls_server.zig:506 — hand-transcribed mbedtls error codes/config enums have no drift guard, unlike sizes/alignment.**
All ten values currently match the pinned 3.6.7 headers; the exposure is the eventual version bump, where a renumbered close_notify code would silently invert truncation-detection semantics. The sizes-vs-constants asymmetry is principled (hash-pinned version), so this is cheap insurance, not an oversight to be alarmed about. Fix: export the constants from mbedtls_shim.c and verify once at init. All ten values currently match the pinned 3.6.7 headers; the exposure is the eventual version bump, where a renumbered close_notify code would silently invert truncation-detection semantics. The sizes-vs-constants asymmetry is principled (hash-pinned version), so this is cheap insurance, not an oversight to be alarmed about. Fix: export the constants from mbedtls_shim.c and verify once at init.
## Theme 6: Concurrency, locking and lifecycle coupling (6 findings) ## Theme 6: Concurrency, locking and lifecycle coupling (6 findings)
@@ -194,7 +194,7 @@ The repo's history proves this theme's stakes: commit 35f2324 fixed a process-ki
- **[medium — CLOSED m16] src/web/sse.zig:153 — Hub has no shutdown broadcast; graceful drain stalls up to 15s per idle SSE subscriber.** - **[medium — CLOSED m16] src/web/sse.zig:153 — Hub has no shutdown broadcast; graceful drain stalls up to 15s per idle SSE subscriber.**
Socket shutdown cannot wake a task parked on the per-slot Event; only the heartbeat write fails, up to 15s later — measured: the SSE integration teardown sits exactly 15s in group.await. Production is masked by the cancel path, but the documented "shutdown unblocks every connection" contract is violated. Fix: Hub.close(io) setting a shutdown flag and signaling every active slot's event, called from beginShutdown. Socket shutdown cannot wake a task parked on the per-slot Event; only the heartbeat write fails, up to 15s later — measured: the SSE integration teardown sits exactly 15s in group.await. Production is masked by the cancel path, but the documented "shutdown unblocks every connection" contract is violated. Fix: Hub.close(io) setting a shutdown flag and signaling every active slot's event, called from beginShutdown.
- **[low] src/web/handlers/groups.zig:70 — reload runs outside config_lock in five handlers but inside it in local.zig; the unlocked variant is safe only via an undocumented Manager invariant.** - **[low — CLOSED m19] src/web/handlers/groups.zig:70 — reload runs outside config_lock in five handlers but inside it in local.zig; the unlocked variant is safe only via an undocumented Manager invariant.**
Safety depends on Manager.reload re-reading the database under its own writer_lock — a cross-module fact stated nowhere at the call sites; changing reload to accept pre-read rows (the shape swapLocalTables already has) would silently regress five handlers with the exact ordering bug the milestone-8 review already fixed once in local.zig. Fix: document the re-read requirement at mutations.reload, or hold config_lock uniformly and document the cost. Safety depends on Manager.reload re-reading the database under its own writer_lock — a cross-module fact stated nowhere at the call sites; changing reload to accept pre-read rows (the shape swapLocalTables already has) would silently regress five handlers with the exact ordering bug the milestone-8 review already fixed once in local.zig. Fix: document the re-read requirement at mutations.reload, or hold config_lock uniformly and document the cost.
- **[low — CLOSED m16] src/web/handlers/settings.zig:312 — config_lock held across argon2id hashing (19 MiB, t=2) in the settings PUT.** - **[low — CLOSED m16] src/web/handlers/settings.zig:312 — config_lock held across argon2id hashing (19 MiB, t=2) in the settings PUT.**
@@ -220,42 +220,42 @@ The repo's history proves this theme's stakes: commit 35f2324 fixed a process-ki
- **[medium — CLOSED m16] src/web/api_limiter.zig:210 — ApiLimiter.sweep is never called in production.** - **[medium — CLOSED m16] src/web/api_limiter.zig:210 — ApiLimiter.sweep is never called in production.**
Designed for background reclamation (preallocated stale_keys so sweep never allocates), but only the DNS-side limiter got scheduled in runMaintenance. Once 4096 distinct addresses are seen, the table stays full forever and every unknown-address request pays an O(4096) scan under the limiter mutex. Fix: add it to the maintenance loop, or delete sweep and own inline-eviction-only. Designed for background reclamation (preallocated stale_keys so sweep never allocates), but only the DNS-side limiter got scheduled in runMaintenance. Once 4096 distinct addresses are seen, the table stays full forever and every unknown-address request pays an O(4096) scan under the limiter mutex. Fix: add it to the maintenance loop, or delete sweep and own inline-eviction-only.
- **[low] src/server/handler.zig:255 — two tracker-mutex acquisitions per query (scan + full stats copy) purely to mirror one counter.** - **[low — CLOSED m19] src/server/handler.zig:255 — two tracker-mutex acquisitions per query (scan + full stats copy) purely to mirror one counter.**
Fix: track() returns fullness, or dropped_full becomes an atomic readable without the mutex. Fix: track() returns fullness, or dropped_full becomes an atomic readable without the mutex.
## Theme 8: Dead code, hardcoded values, frontend state hygiene (11 findings) ## Theme 8: Dead code, hardcoded values, frontend state hygiene (11 findings)
- **[medium] web/src/features/settings/SettingsPage.tsx:16 — settings field registry is stringly typed and cast-driven.** - **[medium — CLOSED m19] web/src/features/settings/SettingsPage.tsx:16 — settings field registry is stringly typed and cast-driven.**
A typo'd key compiles, renders undefined, and breaks the field (verification: an edited typo'd key is actually sent and rejected at save — still broken, tsc still green). Fix: `key: keyof Settings[S]` via a generic SectionDef, which removes every cast and makes drift a compile error. A typo'd key compiles, renders undefined, and breaks the field (verification: an edited typo'd key is actually sent and rejected at save — still broken, tsc still green). Fix: `key: keyof Settings[S]` via a generic SectionDef, which removes every cast and makes drift a compile error.
- **[medium] web/src/features/blocklists/BlocklistsPage.tsx:33 — blocklist refresh status lives in an observer-less query-cache entry.** - **[medium — CLOSED m19] web/src/features/blocklists/BlocklistsPage.tsx:33 — blocklist refresh status lives in an observer-less query-cache entry.**
Read with non-subscribing getQueryData (renders only via the mutation's coincidental re-render) and garbage-collected after 5 minutes, after which the page claims no snapshot ever existed. This is client UI state, not server-cache state. Fix: a small store (the restartBanner useSyncExternalStore pattern already in-repo) or a real subscriber with gcTime: Infinity. Read with non-subscribing getQueryData (renders only via the mutation's coincidental re-render) and garbage-collected after 5 minutes, after which the page claims no snapshot ever existed. This is client UI state, not server-cache state. Fix: a small store (the restartBanner useSyncExternalStore pattern already in-repo) or a real subscriber with gcTime: Infinity.
- **[low] src/dns/edns.zig:111 — parseEcs, Ecs, ecs_family_*, extendedRcode have zero production callers.** - **[low — CLOSED m19] src/dns/edns.zig:111 — parseEcs, Ecs, ecs_family_*, extendedRcode have zero production callers.**
stripEcs filters by option code without decoding; forward mode keys on raw bytes; no path composes an extended RCODE (see BADVERS). The project's own precedent deleted ecsPayload when it lost its caller. Fix: wire extendedRcode into a BADVERS path (giving it a caller) or delete both and let git history resurrect them. stripEcs filters by option code without decoding; forward mode keys on raw bytes; no path composes an extended RCODE (see BADVERS). The project's own precedent deleted ecsPayload when it lost its caller. Fix: wire extendedRcode into a BADVERS path (giving it a caller) or delete both and let git history resurrect them.
- **[low] src/web/handlers/settings.zig:354 — dead errdefer in writeSettings implies protection it cannot provide.** - **[low — CLOSED m19] src/web/handlers/settings.zig:354 — dead errdefer in writeSettings implies protection it cannot provide.**
The function returns `?db.Error`, so every exit is a value return and the errdefer never fires; the real safety is three explicit rollbacks below it. Fix: return `db.Error!void` so the spec's canonical errdefer pattern becomes live and the manual rollbacks collapse. The function returns `?db.Error`, so every exit is a value return and the errdefer never fires; the real safety is three explicit rollbacks below it. Fix: return `db.Error!void` so the spec's canonical errdefer pattern becomes live and the manual rollbacks collapse.
- **[low] web/src/features/settings/SettingsPage.tsx:220 — the settings diff compares live query data against a mount-time clone.** - **[low — CLOSED m19] web/src/features/settings/SettingsPage.tsx:220 — the settings diff compares live query data against a mount-time clone.**
A background refetch makes out-of-band changes appear as user edits; Save silently reverts them. Fix: track dirty keys and rebase untouched fields, or freeze the baseline. A background refetch makes out-of-band changes appear as user edits; Save silently reverts them. Fix: track dirty keys and rebase untouched fields, or freeze the baseline.
- **[low] web/src/features/rules/RulesPage.tsx:24 — default group id 1 encoded four ways with two fallback semantics.** - **[low — CLOSED m19] web/src/features/rules/RulesPage.tsx:24 — default group id 1 encoded four ways with two fallback semantics.**
RulesPage preselects the alphabetically first group (the API orders by name), not the default group. Fix: one exported DEFAULT_GROUP_ID and a defaultGroupId(groups) helper. RulesPage preselects the alphabetically first group (the API orders by name), not the default group. Fix: one exported DEFAULT_GROUP_ID and a defaultGroupId(groups) helper.
- **[low] web/src/routes.tsx:97 — the dashboard loader's Promise.all makes the page's per-widget error handling unreachable on first load.** - **[low — CLOSED m19] web/src/routes.tsx:97 — the dashboard loader's Promise.all makes the page's per-widget error handling unreachable on first load.**
One failing endpoint blanks the whole dashboard on cold navigation; the graceful per-section degrade only runs for background refetches. Fix: prime without throwing (allSettled / fire-and-forget) so the granular UI owns failures. One failing endpoint blanks the whole dashboard on cold navigation; the graceful per-section degrade only runs for background refetches. Fix: prime without throwing (allSettled / fire-and-forget) so the granular UI owns failures.
- **[low] web/src/lib/queries.ts:99 — raw `["lookup"]` / `["groups"]` literals bypass the queryKeys table at eight sites.** - **[low — CLOSED m19] web/src/lib/queries.ts:99 — raw `["lookup"]` / `["groups"]` literals bypass the queryKeys table at eight sites.**
A key restructure updates typed usages and leaves the literals silently invalidating nothing. Fix: a `lookupAll` entry and using queryKeys.groups. A key restructure updates typed usages and leaves the literals silently invalidating nothing. Fix: a `lookupAll` entry and using queryKeys.groups.
- **[low] src/storage/logger.zig:44 — Entry buffer sizes are file-private and re-hardcoded by callers whose comptime guards check their own copies.** - **[low — CLOSED m19] src/storage/logger.zig:44 — Entry buffer sizes are file-private and re-hardcoded by callers whose comptime guards check their own copies.**
handler.zig's reason-length guard proves fit against its local 32, not logger's constant; shrinking logger's buffer passes the guard and silently truncates. Fix: make the constants pub and point the guards at them. (The two max_ip_text=45 copies derive from RFC 5952 independently; that portion is marginal.) handler.zig's reason-length guard proves fit against its local 32, not logger's constant; shrinking logger's buffer passes the guard and silently truncates. Fix: make the constants pub and point the guards at them. (The two max_ip_text=45 copies derive from RFC 5952 independently; that portion is marginal.)
- **[low] src/app.zig:609 — dev-mode asset directory in a module-level mutable global.** - **[low — CLOSED m19] src/app.zig:609 — dev-mode asset directory in a module-level mutable global.**
Verification corrected the root cause: the fallback pointer already receives *WebState and discards it; the fix is a `dev_dir` field on WebState set at the composition root — not the signature-widening the finding proposed. Verification corrected the root cause: the fallback pointer already receives *WebState and discards it; the fix is a `dev_dir` field on WebState set at the composition root — not the signature-widening the finding proposed.
- **[low] src/server/cert_store.zig:326 — private key PEM freed without zeroization.** - **[low — CLOSED m19] src/server/cert_store.zig:326 — private key PEM freed without zeroization.**
Occurs at boot and on actual cert renewals (not every poll); the parsed key stays resident in mbedTLS regardless, so this closes only the freed-page-reuse exposure. Fix must wipe inside readPem (readFileAllocOptions may reallocate, leaving intermediate copies), not just a defer at the call site. Occurs at boot and on actual cert renewals (not every poll); the parsed key stays resident in mbedTLS regardless, so this closes only the freed-page-reuse exposure. Fix must wipe inside readPem (readFileAllocOptions may reallocate, leaving intermediate copies), not just a defer at the call site.
--- ---
+11 -2
View File
@@ -9,7 +9,10 @@
# #
# The builder stage stages the CA bundle (upstream DoH/DoT verification rescans # The builder stage stages the CA bundle (upstream DoH/DoT verification rescans
# the system store; a scratch image without one breaks every TLS upstream) and # the system store; a scratch image without one breaks every TLS upstream) and
# maps the buildx TARGETARCH onto the zig cross-target directory. # maps the buildx TARGETARCH onto the zig cross-target directory. The legacy
# builder leaves TARGETARCH empty, so the arch falls back to the build host's
# `uname -m`: a plain `docker build` must never package a foreign binary that
# only fails at `docker run` with exec-format.
FROM alpine:3.22 AS builder FROM alpine:3.22 AS builder
RUN apk add --no-cache ca-certificates RUN apk add --no-cache ca-certificates
@@ -17,7 +20,13 @@ ARG TARGETARCH
COPY zig-out/cross /cross COPY zig-out/cross /cross
RUN mkdir -p /rootfs/etc/ssl/certs /rootfs/etc/nxdns /rootfs/var/lib/nxdns \ RUN mkdir -p /rootfs/etc/ssl/certs /rootfs/etc/nxdns /rootfs/var/lib/nxdns \
&& cp /etc/ssl/certs/ca-certificates.crt /rootfs/etc/ssl/certs/ \ && cp /etc/ssl/certs/ca-certificates.crt /rootfs/etc/ssl/certs/ \
&& case "${TARGETARCH:-amd64}" in \ && arch="${TARGETARCH:-}" \
&& if [ -z "$arch" ]; then case "$(uname -m)" in \
x86_64) arch=amd64 ;; \
aarch64) arch=arm64 ;; \
*) echo "unsupported build host $(uname -m); use buildx" >&2; exit 1 ;; \
esac; fi \
&& case "$arch" in \
amd64) cp /cross/x86_64-linux-musl/nxdns /rootfs/nxdns ;; \ amd64) cp /cross/x86_64-linux-musl/nxdns /rootfs/nxdns ;; \
arm64) cp /cross/aarch64-linux-musl/nxdns /rootfs/nxdns ;; \ arm64) cp /cross/aarch64-linux-musl/nxdns /rootfs/nxdns ;; \
*) echo "unsupported TARGETARCH '${TARGETARCH}'" >&2; exit 1 ;; \ *) echo "unsupported TARGETARCH '${TARGETARCH}'" >&2; exit 1 ;; \
+3 -1
View File
@@ -161,7 +161,9 @@ resolver.
The Dockerfile maps buildx's `TARGETARCH` onto the cross-target directory, so The Dockerfile maps buildx's `TARGETARCH` onto the cross-target directory, so
the aarch64 image comes from the same `zig-out/cross` tree with no second the aarch64 image comes from the same `zig-out/cross` tree with no second
compile: compile. Under the legacy builder, where `TARGETARCH` is empty, the Dockerfile
falls back to the build host's `uname -m`, so a plain `docker build` on the Pi
itself also selects the aarch64 binary:
```sh ```sh
docker buildx build --platform linux/arm64 -t nxdns:arm64 -f deploy/docker/Dockerfile . docker buildx build --platform linux/arm64 -t nxdns:arm64 -f deploy/docker/Dockerfile .
+101
View File
@@ -431,6 +431,107 @@ Deleted surface: `parseEcs`/`Ecs`/`EcsError`/`ecs_family_*` (edns.zig), the
- [ ] Full suite green: `zig build test -Dintegration`, `npm run test`, - [ ] Full suite green: `zig build test -Dintegration`, `npm run test`,
`npm run typecheck`, `npm run lint`. `npm run typecheck`, `npm run lint`.
## Recorded (implementation)
Accepted deviations and findings from the built milestone.
### Ruling 1 (S1)
- The edns.zig test block held six `parseEcs` tests, not nine. A seventh
use lived inside "encodeOpt round-trips through record parse and
parseOpt", which is not a parseEcs test; its last two lines now compare
the found option's bytes directly.
- `extendedRcode` stays, with a nuance the ruling's premise missed: the
BADVERS production path uses the write-side `splitRcode`; extendedRcode
is the read-side verifier whose callers are the two BADVERS wire-format
tests (handler.zig, packet.zig). Deleting it would weaken those tests.
- The write-fault seam for the rollback test has no exported control
surface — the test lives in the same file and needs none.
- The ruling-8 doc comment names `local.zig`'s `publish` function instead
of a line number, so it cannot go stale.
### Rulings 3-7 (S2)
- Ownership amendment: `src/web/metrics.zig` (owned by no session) was
granted to S2 mid-flight. Ruling 5's new counter raises the /metrics
sample count 29 → 30 (`nxdns_log_lines_truncated_total`); the
hard-coded assertion became fully derived
(`1 + dns_stat_fields.len + @typeInfo(LoggerCounters)... +
@typeInfo(logging.Stats)...`), so a new counter in any of those
structs extends the exposition and the assertion together.
- Ruling 6 lands asymmetrically, following the ruling body over the
stale acceptance bullet: doh_client.zig names eleven members (the two
collapsed top-level ones plus the nine `Tls*` members of the unwrapped
read-cause set); fetcher.zig names only the two, because it has no
cause unwrap and no record-layer member can reach it. Both files carry
a comment pointing at the other.
- Addition beyond the ruling: an exact switch does not catch a std
rename — `error.X` in an expression names a member into existence, so
a renamed member leaves the switch compiling and matching nothing.
Comptime guards close this for real: doh_client.zig asserts every
member of `std.crypto.tls.Client.ReadError` maps to `TlsFailed`, and
both files assert the two collapsed names still exist in
`std.http.Client.RequestError`.
- The ruling's "stashed record-layer cause through the unwrap" test is
not constructible: the stub connection is `.plain` on purpose, and a
plain connection's stashed error type has no `Tls*` member. The
record-layer set is instead tested by an `inline for` over the std
error set against `mapError` — all nine members, not one sample.
- Ruling 3 behavioral note: queries.zig's client width dropped 64 → 45
as directed; a `?client=` filter of 46-64 bytes now returns 400
instead of matching nothing. `src/filter/wildcard.zig`'s
`max_pattern_len = 253` is a rule-pattern limit, not a logger role,
and stays.
### Rulings 14-20 (S3)
- Ruling 14: the three `Record<string, unknown>` consumption casts
collapse into one documented cast inside `sectionValues` rather than
zero — iterating the heterogeneous registry erases the per-section
correlation TypeScript needs, and `TlsListenerSettings` (a named
interface, no implicit index signature) blocks the assignment route
with types.ts off-limits. Four casts before, one after.
- Ruling 20: the catch body lives in an exported `swallowMutationError`
helper because jsdom never fires `unhandledrejection`, so a rethrow
from an async submit handler cannot be asserted directly; the helper
is tested directly, mirroring how auth/store.tsx's logout pair is
tested.
- The refresh store notifies synchronously inside `onSuccess`, one flush
before react-query's success dispatch; one pre-existing assertion
moved from `getByText` to `await findByText`. The sub-frame transient
was judged cheaper than batching through notifyManager.
- `lib/queries.ts` imports `features/blocklists/refreshStore.ts` (lib →
features) because the spec directs the mutation factory to write the
store; recorded as sanctioned layering.
- `SourceStatusSection`'s prop is `SourceStatus[] | null` to match the
store; BlocklistsPage tests clear the module-level store in
`beforeEach`.
- Ruling 18: post-m18 there are four other `Promise.all` loaders, not
five.
### Rulings 12, 13 and carry-ins (S4)
- The undecodable-sibling message reports `decompress.err orelse err`,
because `std.Io.Reader` collapses every decode failure into
`error.ReadFailed` and stashes the real one.
- The Dockerfile keeps its `&&` chain instead of the spec sketch's `;`
separators, so any failed step still aborts the layer; the case arms
are untouched.
- The phase-comment sweep leaves `PLAN.md:605`/`:609` alone — they are
the section headings that define Phase 7 and Phase 8, not stale
comments.
- The orchestrator added the host-fallback sentence to
docs/how-to/install-with-docker.md.
### Residual (named, not fixed)
`fetcher.zig` hands the collapsed `ReadFailed`/`WriteFailed` to
`mapError` with no cause unwrap, so a blocklist download canceled at
shutdown returns `error.ReceiveFailed` instead of `error.Canceled` — the
class m18 ruling 4 fixed for DoH. Consequence: a wrong source status and
a spurious warn line at shutdown. Fixing it needs `req`/`resp` plumbing
at four call sites; it deserves its own ruling.
## Anti-requirements ## Anti-requirements
- No deletion of `extendedRcode` (m17 gave it a caller) and no deletion of - No deletion of `extendedRcode` (m17 gave it a caller) and no deletion of
+2 -10
View File
@@ -465,8 +465,6 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// Everything the web layer borrows lives above; the group below cancels the // Everything the web layer borrows lives above; the group below cancels the
// web task before any of it is released. With the web interface disabled // web task before any of it is released. With the web interface disabled
// the state stays in its null-defaulted shape and no task reads it. // the state stays in its null-defaulted shape and no task reads it.
if (args.web_dev) |dir| web_dev_dir = dir;
var web_state: web_server.WebState = .{ .gpa = gpa }; var web_state: web_server.WebState = .{ .gpa = gpa };
// The live hash may own a gpa replacement after a settings PUT; this defer // The live hash may own a gpa replacement after a settings PUT; this defer
// runs after `group.cancel` below, so no web task can still read it. // runs after `group.cancel` below, so no web task can still read it.
@@ -499,6 +497,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// Ruling 24: `--web-dev` serves from disk with no cache headers; // Ruling 24: `--web-dev` serves from disk with no cache headers;
// otherwise the embedded assets answer every non-/api miss. // otherwise the embedded assets answer every non-/api miss.
.fallback = if (args.web_dev != null) serveWebDev else static.fallback, .fallback = if (args.web_dev != null) serveWebDev else static.fallback,
.dev_dir = args.web_dev orelse "",
.reload_fn = reloadManager, .reload_fn = reloadManager,
}; };
@@ -634,12 +633,6 @@ fn reloadManager(state: *web_server.WebState, io: std.Io) anyerror!void {
try manager.reload(io); try manager.reload(io);
} }
/// The `--web-dev` directory. `WebState.fallback` is a bare function pointer
/// with no closure to carry the path, and one process runs one composition
/// root, so the directory lives here: written once by `serve` before the web
/// task starts, read only by `serveWebDev`.
var web_dev_dir: []const u8 = "";
/// Dev-mode asset serving (ruling 24): straight from disk, no cache headers, /// Dev-mode asset serving (ruling 24): straight from disk, no cache headers,
/// so an edit shows up on the next reload. /// so an edit shows up on the next reload.
fn serveWebDev( fn serveWebDev(
@@ -647,8 +640,7 @@ fn serveWebDev(
io: std.Io, io: std.Io,
request: *http_util.Request, request: *http_util.Request,
) http_util.HandlerError!void { ) http_util.HandlerError!void {
_ = state; return static.serveFromDisk(state.dev_dir, io, request);
return static.serveFromDisk(web_dev_dir, io, request);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+6 -4
View File
@@ -7,7 +7,8 @@
//! buffer and allocates nothing. //! buffer and allocates nothing.
//! //!
//! **Not thread-safe.** No lock guards the slots, the index or the counters. //! **Not thread-safe.** No lock guards the slots, the index or the counters.
//! Phase 7 decides the locking when it wires the cache into the query path. //! The query path owns the lock: `handler.Handler.cache_mutex` is held across
//! every `get`, `put` and `sweep`.
//! //!
//! The store is a fixed array of slots plus a hash index from key bytes to slot //! The store is a fixed array of slots plus a hash index from key bytes to slot
//! number, both sized at `init` and never resized: a household resolver must //! number, both sized at `init` and never resized: a household resolver must
@@ -39,8 +40,8 @@ pub const max_key_len = types.max_name_len + 1 + 2 + 2 + 1 + 1 + max_ecs_len;
/// Writes the cache key into `buf` and returns the written prefix. /// Writes the cache key into `buf` and returns the written prefix.
/// ///
/// ECS participates only when the caller passes it. Phase 7 passes the /// ECS participates only when the caller passes it. `handler.Context.cacheKey`
/// forwarded subnet under `ecs_mode=forward` and nothing otherwise (PLAN §8), /// passes the forwarded subnet under `ecs_mode=forward` and nothing else (PLAN §8),
/// so a deployment that does not forward ECS pays no key-space split for it. /// so a deployment that does not forward ECS pays no key-space split for it.
/// ///
/// The length bounds are assertions, not errors: both values reach here from /// The length bounds are assertions, not errors: both values reach here from
@@ -436,7 +437,8 @@ pub const DnsCache = struct {
return copy; return copy;
} }
/// Removes every expired entry and returns how many. Phase 7 schedules it; /// Removes every expired entry and returns how many. `app.maintenanceOnce`
/// schedules it;
/// expiry is also enforced on access, so this only reclaims memory held by /// expiry is also enforced on access, so this only reclaims memory held by
/// names nobody asks for any more. /// names nobody asks for any more.
pub fn sweep(self: *DnsCache, now_s: i64) u32 { pub fn sweep(self: *DnsCache, now_s: i64) u32 {
+2 -2
View File
@@ -329,8 +329,8 @@ pub const DataDir = struct {
} }
/// An additional connection to a `querylog.db` that `openQuerylogDb` has /// An additional connection to a `querylog.db` that `openQuerylogDb` has
/// already established. Phase 7 needs two — the log writer and the retention /// already established. A running server needs two — the log writer and the
/// pass each own one (`retention.zig`'s contract). /// retention pass each own one (`retention.zig`'s contract).
pub fn reopenQuerylogDb(self: *const DataDir, io: std.Io) !db.Db { pub fn reopenQuerylogDb(self: *const DataDir, io: std.Io) !db.Db {
_ = io; _ = io;
var database = try db.Db.open(self.querylog_db_path, .{ .mode = .read_write_existing }); var database = try db.Db.open(self.querylog_db_path, .{ .mode = .read_write_existing });
+5 -130
View File
@@ -90,59 +90,6 @@ pub fn findOption(packet: []const u8, opt: OptRecord, code: u16) error{BadOption
return null; return null;
} }
/// Address families in the EDNS Client Subnet option, from the IANA Address
/// Family Numbers registry.
pub const ecs_family_ipv4: u16 = 1;
pub const ecs_family_ipv6: u16 = 2;
pub const Ecs = struct {
family: u16,
source_prefix: u8,
scope_prefix: u8,
/// The truncated address, `ceil(source_prefix / 8)` bytes, as a slice into
/// the option data.
address: []const u8,
};
pub const EcsError = error{BadEcs};
/// RFC 7871 §6: FAMILY, SOURCE PREFIX-LENGTH, SCOPE PREFIX-LENGTH, then only
/// as many address bytes as the source prefix covers.
pub fn parseEcs(data: []const u8) EcsError!Ecs {
if (data.len < 4) return error.BadEcs;
const family = std.mem.readInt(u16, data[0..2], .big);
const source_prefix = data[2];
const scope_prefix = data[3];
const max_prefix: u16 = switch (family) {
ecs_family_ipv4 => 32,
ecs_family_ipv6 => 128,
// An unknown family has no known address width, so only the encoded
// length can be checked.
else => 255,
};
if (source_prefix > max_prefix) return error.BadEcs;
if (scope_prefix > max_prefix) return error.BadEcs;
// RFC 7871 §6 truncates the address to the source prefix and pads the last
// octet with zero bits, so a prefix of 0 carries no address bytes at all.
const address_len = (@as(usize, source_prefix) + 7) / 8;
if (data.len - 4 != address_len) return error.BadEcs;
const significant_bits: u3 = @intCast(source_prefix % 8);
if (significant_bits != 0) {
const padding_mask = @as(u8, 0xff) >> significant_bits;
if (data[3 + address_len] & padding_mask != 0) return error.BadEcs;
}
return .{
.family = family,
.source_prefix = source_prefix,
.scope_prefix = scope_prefix,
.address = data[4..],
};
}
/// Writes the OPT record: root owner name, type OPT, the payload size in /// Writes the OPT record: root owner name, type OPT, the payload size in
/// CLASS, the flags in TTL, then the option list verbatim. /// CLASS, the flags in TTL, then the option list verbatim.
pub fn encodeOpt(opt: OptRecord, options_bytes: []const u8, w: *Writer) (Writer.Error || error{OptionsTooLong})!void { pub fn encodeOpt(opt: OptRecord, options_bytes: []const u8, w: *Writer) (Writer.Error || error{OptionsTooLong})!void {
@@ -386,81 +333,6 @@ test "parseOpt rejects a malformed option list" {
try testing.expectError(error.BadOption, parseOpt(partial, rp.record)); try testing.expectError(error.BadOption, parseOpt(partial, rp.record));
} }
test "parseEcs reads an IPv4 subnet" {
const ecs = try parseEcs("\x00\x01\x18\x00\xc0\x00\x02");
try testing.expectEqual(ecs_family_ipv4, ecs.family);
try testing.expectEqual(@as(u8, 24), ecs.source_prefix);
try testing.expectEqual(@as(u8, 0), ecs.scope_prefix);
try testing.expectEqualSlices(u8, "\xc0\x00\x02", ecs.address);
}
test "parseEcs reads an IPv6 subnet" {
const ecs = try parseEcs("\x00\x02\x38\x38\x20\x01\x0d\xb8\x00\x00\x00");
try testing.expectEqual(ecs_family_ipv6, ecs.family);
try testing.expectEqual(@as(u8, 56), ecs.source_prefix);
try testing.expectEqual(@as(u8, 56), ecs.scope_prefix);
try testing.expectEqual(@as(usize, 7), ecs.address.len);
}
test "parseEcs reads a zero-length prefix" {
const ecs = try parseEcs("\x00\x01\x00\x00");
try testing.expectEqual(@as(u8, 0), ecs.source_prefix);
try testing.expectEqual(@as(usize, 0), ecs.address.len);
// A prefix of 0 covers no address byte, so any address byte is a length
// mismatch.
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x00\x00\x00"));
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x00\x00\xc0\x00\x02\x00"));
}
test "parseEcs rejects nonzero padding bits past the source prefix" {
// IPv4 /25: the low seven bits of the fourth address byte must be zero.
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x19\x00\xc0\x00\x02\x01"));
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x19\x00\xc0\x00\x02\xff"));
const zero_padded = try parseEcs("\x00\x01\x19\x00\xc0\x00\x02\x80");
try testing.expectEqual(@as(u8, 25), zero_padded.source_prefix);
try testing.expectEqualSlices(u8, "\xc0\x00\x02\x80", zero_padded.address);
// IPv4 /20: the low four bits of the third address byte must be zero.
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x14\x00\xc0\x00\x0f"));
try testing.expectEqualSlices(u8, "\xc0\x00\x00", (try parseEcs("\x00\x01\x14\x00\xc0\x00\x00")).address);
// IPv6 /57: the low seven bits of the eighth address byte must be zero.
try testing.expectError(error.BadEcs, parseEcs("\x00\x02\x39\x00\x20\x01\x0d\xb8\x00\x00\x00\x7f"));
try testing.expectEqualSlices(
u8,
"\x20\x01\x0d\xb8\x00\x00\x00\x80",
(try parseEcs("\x00\x02\x39\x00\x20\x01\x0d\xb8\x00\x00\x00\x80")).address,
);
// An unknown family uses the same encoding, so the rule holds there too.
try testing.expectError(error.BadEcs, parseEcs("\x12\x34\x03\x00\xff"));
try testing.expectEqualSlices(u8, "\xe0", (try parseEcs("\x12\x34\x03\x00\xe0")).address);
}
test "parseEcs rejects malformed options" {
// Shorter than the fixed fields.
try testing.expectError(error.BadEcs, parseEcs(""));
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x18"));
// Prefix wider than the family allows.
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x21" ++ "\x00\x00\x00\x00\x00"));
try testing.expectError(error.BadEcs, parseEcs("\x00\x02\x81" ++ "\x00" ** 17));
// Scope wider than the family allows.
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x18\x21\xc0\x00\x02"));
// Address shorter than the prefix needs.
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x18\x00\xc0\x00"));
// Address longer than the prefix needs.
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x18\x00\xc0\x00\x02\x00"));
}
test "parseEcs accepts an unknown family with a consistent length" {
const ecs = try parseEcs("\x12\x34\x08\x00\xff");
try testing.expectEqual(@as(u16, 0x1234), ecs.family);
try testing.expectEqualSlices(u8, "\xff", ecs.address);
try testing.expectError(error.BadEcs, parseEcs("\x12\x34\x08\x00\xff\xff"));
}
test "encodeOpt round-trips through record parse and parseOpt" { test "encodeOpt round-trips through record parse and parseOpt" {
const original: OptRecord = .{ const original: OptRecord = .{
.udp_payload_size = 1232, .udp_payload_size = 1232,
@@ -485,8 +357,11 @@ test "encodeOpt round-trips through record parse and parseOpt" {
try testing.expectEqual(original.do_bit, opt.do_bit); try testing.expectEqual(original.do_bit, opt.do_bit);
try testing.expectEqualSlices(u8, options_bytes, opt.options.slice(bytes)); try testing.expectEqualSlices(u8, options_bytes, opt.options.slice(bytes));
const ecs = try parseEcs((try findOption(bytes, opt, ecs_option_code)).?.data); try testing.expectEqualSlices(
try testing.expectEqual(@as(u8, 24), ecs.source_prefix); u8,
"\x00\x01\x18\x00\xc0\x00\x02",
(try findOption(bytes, opt, ecs_option_code)).?.data,
);
} }
test "encodeOpt round-trips both DO states" { test "encodeOpt round-trips both DO states" {
+33 -6
View File
@@ -156,6 +156,32 @@ fn parseUrl(url: []const u8) Error!std.Uri {
/// first time two phases shared an error. /// first time two phases shared an error.
const Phase = enum { connect, send, receive }; const Phase = enum { connect, send, receive };
// `error.X` in an expression names a member into existence rather than
// referring to one, so the switch in `mapError` would keep compiling — and
// silently stop matching — if std renamed either of these. This is what fails
// the build instead.
comptime {
for ([_][]const u8{ "TlsInitializationFailed", "CertificateBundleLoadFailure" }) |name| {
if (!errorSetHas(std.http.Client.RequestError, name)) {
@compileError("std.http.Client.RequestError no longer names " ++ name);
}
}
}
fn errorSetHas(comptime Set: type, comptime name: []const u8) bool {
for (@typeInfo(Set).error_set.?) |member| {
if (std.mem.eql(u8, member.name, name)) return true;
}
return false;
}
/// The two names are the whole TLS surface this file can reach.
/// `std.http.Client` collapses every handshake fault into
/// `error.TlsInitializationFailed` (Client.zig:1470) and every bundle fault into
/// `error.CertificateBundleLoadFailure`, and this file never unwraps a
/// connection's stashed read cause — it maps the collapsed `error.ReadFailed` by
/// phase — so the record-layer members of `std.crypto.tls.Client.ReadError`
/// cannot arrive here. `doh_client.zig` does unwrap, and names them.
fn mapError(err: anyerror, phase: Phase) Error { fn mapError(err: anyerror, phase: Phase) Error {
if (transport.mapLocal(err)) |local| return narrowLocal(local); if (transport.mapLocal(err)) |local| return narrowLocal(local);
switch (err) { switch (err) {
@@ -165,9 +191,10 @@ fn mapError(err: anyerror, phase: Phase) Error {
error.TooManyHttpRedirects => return error.HttpStatus, error.TooManyHttpRedirects => return error.HttpStatus,
else => {}, else => {},
} }
const err_name = @errorName(err); switch (err) {
if (std.mem.startsWith(u8, err_name, "Tls") or error.TlsInitializationFailed, error.CertificateBundleLoadFailure => return error.TlsFailed,
std.mem.startsWith(u8, err_name, "Certificate")) return error.TlsFailed; else => {},
}
return switch (phase) { return switch (phase) {
.connect => error.ConnectFailed, .connect => error.ConnectFailed,
.send => error.SendFailed, .send => error.SendFailed,
@@ -307,10 +334,10 @@ test "mapError maps local errors before phase errors" {
); );
} }
test "mapError maps tls errors regardless of phase" { test "mapError maps the reachable tls errors regardless of phase" {
try testing.expectEqual(error.TlsFailed, mapError(error.TlsInitializationFailed, .connect)); try testing.expectEqual(error.TlsFailed, mapError(error.TlsInitializationFailed, .connect));
try testing.expectEqual(error.TlsFailed, mapError(error.TlsAlert, .receive)); try testing.expectEqual(error.TlsFailed, mapError(error.TlsInitializationFailed, .receive));
try testing.expectEqual(error.TlsFailed, mapError(error.CertificateExpired, .connect)); try testing.expectEqual(error.TlsFailed, mapError(error.CertificateBundleLoadFailure, .connect));
} }
test "mapError maps a redirect overrun to HttpStatus" { test "mapError maps a redirect overrun to HttpStatus" {
+1 -1
View File
@@ -1388,7 +1388,7 @@ fn requestHeader() header.Header {
return (packet.parse(opt_query) catch unreachable).header; return (packet.parse(opt_query) catch unreachable).header;
} }
/// Answers `qname`/`qtype` from `table`, exactly as the Phase 7 handler will: /// Answers `qname`/`qtype` from `table`, exactly as `server/handler.zig` does:
/// look the name up, then write the run it returns into a response. /// look the name up, then write the run it returns into a response.
fn answerLocal( fn answerLocal(
table: *const records.Records, table: *const records.Records,
+5 -4
View File
@@ -289,7 +289,7 @@ pub const Manager = struct {
/// what every test and `nxdns check` want. Only the scheduler consults it — /// what every test and `nxdns check` want. Only the scheduler consults it —
/// see `refreshGated`. /// see `refreshGated`.
monitor: ?*disk_monitor.Monitor = null, monitor: ?*disk_monitor.Monitor = null,
/// Scheduled refresh passes skipped by the disk gate. Phase 8's health /// Scheduled refresh passes skipped by the disk gate. The `/api/health`
/// rollup reads it through `refreshesGated`. /// rollup reads it through `refreshesGated`.
refreshes_gated: std.atomic.Value(u64) = .init(0), refreshes_gated: std.atomic.Value(u64) = .init(0),
@@ -610,8 +610,8 @@ pub const Manager = struct {
/// ///
/// The status entry is found by row id, so a source added since the last /// The status entry is found by row id, so a source added since the last
/// `reload` has nowhere to record its outcome. `refreshAll` syncs the table /// `reload` has nowhere to record its outcome. `refreshAll` syncs the table
/// before it refreshes anything, which is why it is the entry point Phase 8 /// before it refreshes anything, which is why `POST /api/blocklists/update`
/// and the scheduler use. /// and the scheduler both enter through `refreshAll`.
pub fn refreshSource(self: *Manager, io: std.Io, row: sources_repo.SourceRow) Error!bool { pub fn refreshSource(self: *Manager, io: std.Io, row: sources_repo.SourceRow) Error!bool {
self.refresh_lock.lockUncancelable(io); self.refresh_lock.lockUncancelable(io);
defer self.refresh_lock.unlock(io); defer self.refresh_lock.unlock(io);
@@ -1142,7 +1142,8 @@ pub const Manager = struct {
/// a critically full disk must not take. /// a critically full disk must not take.
/// ///
/// `reload` and `refreshAll` are deliberately not gated: both are operator /// `reload` and `refreshAll` are deliberately not gated: both are operator
/// actions (the composition root's startup load, Phase 8's manual refresh), /// actions (the composition root's startup load, `POST
/// /api/blocklists/update`),
/// and an operator who asks for a refresh on a full disk has asked for it. /// and an operator who asks for a refresh on a full disk has asked for it.
/// ///
/// Counting happens here, so a caller cannot skip a pass without recording /// Counting happens here, so a caller cannot skip a pass without recording
+1 -1
View File
@@ -55,7 +55,7 @@ pub fn lookup(domain: []const u8) ?[]const u8 {
/// The rewritten question name for a matched query. Applying it — sending the /// The rewritten question name for a matched query. Applying it — sending the
/// rewritten question upstream and prefixing the answer with a CNAME from the /// rewritten question upstream and prefixing the answer with a CNAME from the
/// original name to the target — is the handler's job (Phase 7). Nothing here /// original name to the target — is `server/handler.zig`'s job. Nothing here
/// builds a response. /// builds a response.
pub fn rewrite(domain: []const u8) ?name.Name { pub fn rewrite(domain: []const u8) ?name.Name {
const target = lookup(domain) orelse return null; const target = lookup(domain) orelse return null;
+1 -1
View File
@@ -4,7 +4,7 @@
//! resolver — which speaks port 53 and nothing else. `transport.Endpoint` knows //! resolver — which speaks port 53 and nothing else. `transport.Endpoint` knows
//! only `https://` and `tls://` by design, so the configuration for this client //! only `https://` and `tls://` by design, so the configuration for this client
//! comes from `validate.Resolver` instead. The interface it implements is the //! comes from `validate.Resolver` instead. The interface it implements is the
//! same `transport.Client` every upstream implements, so the Phase 7 handler //! same `transport.Client` every upstream implements, so `server/handler.zig`
//! treats a forward zone exactly like any other exchange. //! treats a forward zone exactly like any other exchange.
//! //!
//! No health tracking and no backoff live here. `upstream/health.zig` and //! No health tracking and no backoff live here. `upstream/health.zig` and
+69 -2
View File
@@ -55,10 +55,19 @@ pub const max_rotated_path_bytes = std.Io.Dir.max_path_bytes + 4;
pub const Stats = struct { pub const Stats = struct {
lines_written: u64 = 0, lines_written: u64 = 0,
lines_deduped: u64 = 0, lines_deduped: u64 = 0,
/// Messages that did not fit `max_message_bytes` and carry the
/// `truncation_marker` in place of their last bytes. Counted where the
/// truncation happens, so a truncated message the dedup window then
/// swallows counts here and under `lines_deduped`.
lines_truncated: u64 = 0,
rotations: u64 = 0, rotations: u64 = 0,
sink_errors: u64 = 0, sink_errors: u64 = 0,
}; };
/// What a truncated message ends in, the same three bytes `safe_url.zig` uses
/// so both truncations read alike to an operator.
pub const truncation_marker = "...";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Level // Level
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -287,8 +296,18 @@ pub fn logFn(
var message_buf: [max_message_bytes]u8 = undefined; var message_buf: [max_message_bytes]u8 = undefined;
var mw: std.Io.Writer = .fixed(&message_buf); var mw: std.Io.Writer = .fixed(&message_buf);
mw.print(format, args) catch {}; // A message longer than the buffer is truncated rather than dropped, but a
const message = mw.buffered(); // silently cut line reads as a complete one: the marker says the sink cut
// it, and the counter says how often that happens.
const message = if (mw.print(format, args)) mw.buffered() else |_| blk: {
state.stats.lines_truncated += 1;
// `@min` rather than a plain subtraction: a `print` that fails on its
// first chunk leaves fewer bytes buffered than the marker is long, and
// the marker still has to fit.
const kept = @min(mw.buffered().len, message_buf.len - truncation_marker.len);
@memcpy(message_buf[kept..][0..truncation_marker.len], truncation_marker);
break :blk message_buf[0 .. kept + truncation_marker.len];
};
if (comptime isDedupScope(scope) and enabled(message_level, .warn)) { if (comptime isDedupScope(scope) and enabled(message_level, .warn)) {
comptime std.debug.assert(@tagName(scope).len <= max_scope_name_bytes); comptime std.debug.assert(@tagName(scope).len <= max_scope_name_bytes);
@@ -1032,3 +1051,51 @@ test "rotation covers max_files - 1 generations" {
try testing.expectEqualStrings("nxdns.log.1", names[0]); try testing.expectEqualStrings("nxdns.log.1", names[0]);
try testing.expectEqualStrings("nxdns.log.4", names[3]); try testing.expectEqualStrings("nxdns.log.4", names[3]);
} }
test "a message over the buffer is marked and counted" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [128]u8 = undefined;
const p = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/nxdns.log", .{tmp.sub_path});
var stderr_buf: [64]u8 = undefined;
_ = std.debug.lockStderr(&stderr_buf);
const saved = state;
defer {
closeFileLocked();
state = saved;
std.debug.unlockStderr();
}
state.stats = .{};
state.io = threaded.io();
state.threshold = .info;
state.output = .file;
state.path_len = p.len;
@memcpy(state.path_buf[0..p.len], p);
state.max_files = 0;
state.max_bytes = 0;
state.file = null;
state.file_pos = 0;
state.rotate_pending = false;
state.installed = true;
const oversized: [max_message_bytes + 100]u8 = @splat('a');
logFn(.info, .default, "{s}", .{&oversized});
try testing.expectEqual(@as(u64, 1), state.stats.lines_truncated);
try testing.expectEqual(@as(u64, 1), state.stats.lines_written);
try testing.expectEqual(@as(u64, 0), state.stats.sink_errors);
closeFileLocked();
var read_buf: [max_message_bytes * 2]u8 = undefined;
const written = try std.Io.Dir.cwd().readFile(state.io, p, &read_buf);
try testing.expect(std.mem.endsWith(u8, written, truncation_marker ++ "\n"));
// The marker replaces the last bytes of the message rather than extending
// it, so the record still fits what the buffer held.
const colon = std.mem.indexOf(u8, written, ": ").?;
try testing.expectEqual(max_message_bytes, written[colon + 2 ..].len - 1);
}
+17
View File
@@ -6,6 +6,7 @@
#include <mbedtls/ctr_drbg.h> #include <mbedtls/ctr_drbg.h>
#include <mbedtls/entropy.h> #include <mbedtls/entropy.h>
#include <mbedtls/net_sockets.h>
#include <mbedtls/pk.h> #include <mbedtls/pk.h>
#include <mbedtls/ssl.h> #include <mbedtls/ssl.h>
#include <mbedtls/x509_crt.h> #include <mbedtls/x509_crt.h>
@@ -33,3 +34,19 @@ size_t nx_max_context_alignment(void)
if (_Alignof(mbedtls_ctr_drbg_context) > max) max = _Alignof(mbedtls_ctr_drbg_context); if (_Alignof(mbedtls_ctr_drbg_context) > max) max = _Alignof(mbedtls_ctr_drbg_context);
return max; return max;
} }
/* The configuration enums and error codes tls_server.zig transcribes from the
* pinned headers. A version bump that renumbers one of these would otherwise
* change behaviour silently -- a moved close_notify inverts the truncation
* check -- so the Zig side compares every value against the getter here. */
int nx_const_ssl_transport_stream(void) { return MBEDTLS_SSL_TRANSPORT_STREAM; }
int nx_const_ssl_is_server(void) { return MBEDTLS_SSL_IS_SERVER; }
int nx_const_ssl_verify_none(void) { return MBEDTLS_SSL_VERIFY_NONE; }
int nx_const_ssl_preset_default(void) { return MBEDTLS_SSL_PRESET_DEFAULT; }
int nx_const_err_ssl_conn_eof(void) { return MBEDTLS_ERR_SSL_CONN_EOF; }
int nx_const_err_ssl_peer_close_notify(void) { return MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY; }
int nx_const_err_ssl_want_read(void) { return MBEDTLS_ERR_SSL_WANT_READ; }
int nx_const_err_ssl_want_write(void) { return MBEDTLS_ERR_SSL_WANT_WRITE; }
int nx_const_err_net_recv_failed(void) { return MBEDTLS_ERR_NET_RECV_FAILED; }
int nx_const_err_net_send_failed(void) { return MBEDTLS_ERR_NET_SEND_FAILED; }
+47
View File
@@ -76,6 +76,7 @@ pub const ServerContext = struct {
alpn: ?[*:null]const ?[*:0]const u8, alpn: ?[*:null]const ?[*:0]const u8,
) InitError!ServerContext { ) InitError!ServerContext {
assert(nx_max_context_alignment() <= context_alignment); assert(nx_max_context_alignment() <= context_alignment);
assert(transcriptionsAgree());
// TLS 1.3 is on in the stock config; its key schedule runs through PSA. // TLS 1.3 is on in the stock config; its key schedule runs through PSA.
const psa_status = psa_crypto_init(); const psa_status = psa_crypto_init();
@@ -671,6 +672,35 @@ extern fn nx_max_context_alignment() usize;
/// The public key of the first certificate in `crt`. /// The public key of the first certificate in `crt`.
extern fn nx_x509_crt_pk(crt: *X509Crt) *PkContext; extern fn nx_x509_crt_pk(crt: *X509Crt) *PkContext;
extern fn nx_const_ssl_transport_stream() c_int;
extern fn nx_const_ssl_is_server() c_int;
extern fn nx_const_ssl_verify_none() c_int;
extern fn nx_const_ssl_preset_default() c_int;
extern fn nx_const_err_ssl_conn_eof() c_int;
extern fn nx_const_err_ssl_peer_close_notify() c_int;
extern fn nx_const_err_ssl_want_read() c_int;
extern fn nx_const_err_ssl_want_write() c_int;
extern fn nx_const_err_net_recv_failed() c_int;
extern fn nx_const_err_net_send_failed() c_int;
/// Every transcribed value beside the header macro it came from. The sizes and
/// alignments have always been read from the shim rather than transcribed;
/// these ten were the exception, and a renumbering after a version bump would
/// change behaviour with nothing to say so — a moved `close_notify` inverts the
/// truncation detection in `read` and `close`.
fn transcriptionsAgree() bool {
return ssl_transport_stream == nx_const_ssl_transport_stream() and
ssl_is_server == nx_const_ssl_is_server() and
ssl_verify_none == nx_const_ssl_verify_none() and
ssl_preset_default == nx_const_ssl_preset_default() and
err_conn_eof == nx_const_err_ssl_conn_eof() and
err_peer_close_notify == nx_const_err_ssl_peer_close_notify() and
err_want_read == nx_const_err_ssl_want_read() and
err_want_write == nx_const_err_ssl_want_write() and
err_net_recv_failed == nx_const_err_net_recv_failed() and
err_net_send_failed == nx_const_err_net_send_failed();
}
// -- tests ----------------------------------------------------------------- // -- tests -----------------------------------------------------------------
test "ServerContext.init accepts the fixture cert and key" { test "ServerContext.init accepts the fixture cert and key" {
@@ -736,6 +766,23 @@ test "the shim agrees with the alignment contexts are allocated at" {
try std.testing.expect(nx_sizeof_ctr_drbg_context() > 0); try std.testing.expect(nx_sizeof_ctr_drbg_context() > 0);
} }
test "the shim agrees with every transcribed mbedtls constant" {
try std.testing.expect(transcriptionsAgree());
// Value by value, so a failure names the one that drifted instead of
// reporting that something did.
try std.testing.expectEqual(ssl_transport_stream, nx_const_ssl_transport_stream());
try std.testing.expectEqual(ssl_is_server, nx_const_ssl_is_server());
try std.testing.expectEqual(ssl_verify_none, nx_const_ssl_verify_none());
try std.testing.expectEqual(ssl_preset_default, nx_const_ssl_preset_default());
try std.testing.expectEqual(err_conn_eof, nx_const_err_ssl_conn_eof());
try std.testing.expectEqual(err_peer_close_notify, nx_const_err_ssl_peer_close_notify());
try std.testing.expectEqual(err_want_read, nx_const_err_ssl_want_read());
try std.testing.expectEqual(err_want_write, nx_const_err_ssl_want_write());
try std.testing.expectEqual(err_net_recv_failed, nx_const_err_net_recv_failed());
try std.testing.expectEqual(err_net_send_failed, nx_const_err_net_send_failed());
}
test "readErrorFor keeps a canceled read out of the TLS failure bucket" { test "readErrorFor keeps a canceled read out of the TLS failure bucket" {
try std.testing.expectEqual(ServerStream.ReadError.Canceled, readErrorFor(error.Canceled)); try std.testing.expectEqual(ServerStream.ReadError.Canceled, readErrorFor(error.Canceled));
try std.testing.expectEqual(ServerStream.ReadError.TlsFailed, readErrorFor(error.ConnectionResetByPeer)); try std.testing.expectEqual(ServerStream.ReadError.TlsFailed, readErrorFor(error.ConnectionResetByPeer));
+67 -2
View File
@@ -318,12 +318,15 @@ fn load(
}; };
defer gpa.free(cert_pem); defer gpa.free(cert_pem);
const key_pem = readPem(gpa, io, key_path) catch |err| return switch (err) { const key_pem = readKeyPem(gpa, io, key_path) catch |err| return switch (err) {
error.OutOfMemory => error.OutOfMemory, error.OutOfMemory => error.OutOfMemory,
error.TooLarge => error.KeyTooLarge, error.TooLarge => error.KeyTooLarge,
error.Unreadable => error.KeyUnreadable, error.Unreadable => error.KeyUnreadable,
}; };
defer gpa.free(key_pem); defer {
std.crypto.secureZero(u8, key_pem);
gpa.free(key_pem);
}
const entry = try gpa.create(Entry); const entry = try gpa.create(Entry);
errdefer gpa.destroy(entry); errdefer gpa.destroy(entry);
@@ -342,6 +345,9 @@ fn load(
/// Mbed TLS wants PEM with a terminating zero byte counted in the length, so /// 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 /// the file lands in a sentinel-terminated allocation. The limit admits
/// exactly `max_pem_bytes` and rejects the first byte beyond it. /// exactly `max_pem_bytes` and rejects the first byte beyond it.
///
/// The certificate only. A private key goes through `readKeyPem`, which does
/// not leave copies behind.
fn readPem( fn readPem(
gpa: std.mem.Allocator, gpa: std.mem.Allocator,
io: std.Io, io: std.Io,
@@ -361,6 +367,36 @@ fn readPem(
}; };
} }
/// `readPem` for the private key, with the same cap and the same
/// sentinel-terminated result. The difference is that no copy of the key
/// survives this function: `readFileAllocOptions` grows its buffer as it reads,
/// and every intermediate copy it abandons stays legible in freed pages that no
/// wipe at the call site can reach. One fixed staging buffer never grows, and it
/// is wiped before it goes back to the allocator. The caller wipes the returned
/// slice the same way before freeing it (the idiom is auth.zig's `secureZero`
/// defers).
fn readKeyPem(
gpa: std.mem.Allocator,
io: std.Io,
path: []const u8,
) error{ OutOfMemory, TooLarge, Unreadable }![:0]u8 {
const staging = try gpa.alloc(u8, max_pem_bytes + 1);
defer {
std.crypto.secureZero(u8, staging);
gpa.free(staging);
}
const bytes = std.Io.Dir.cwd().readFile(io, path, staging) catch return error.Unreadable;
// A read that filled the staging buffer is ambiguous — `readFile` cannot
// say whether more followed — and one byte past `max_pem_bytes` is over the
// cap either way.
if (bytes.len == staging.len) return error.TooLarge;
const key = try gpa.allocSentinel(u8, bytes.len, 0);
@memcpy(key, bytes);
return key;
}
fn statSig(io: std.Io, path: []const u8) !FileSig { fn statSig(io: std.Io, path: []const u8) !FileSig {
const st = try std.Io.Dir.cwd().statFile(io, path, .{}); const st = try std.Io.Dir.cwd().statFile(io, path, .{});
return .{ .mtime_ns = st.mtime.nanoseconds, .size = st.size }; return .{ .mtime_ns = st.mtime.nanoseconds, .size = st.size };
@@ -481,6 +517,35 @@ test "the size cap admits 64 KiB and rejects one byte more" {
try testing.expectError(error.TooLarge, readPem(testing.allocator, io, env.cert_path)); try testing.expectError(error.TooLarge, readPem(testing.allocator, io, env.cert_path));
} }
test "the key PEM path keeps the shape and the cap of the certificate path" {
var env: TestEnv = undefined;
try env.init();
defer env.deinit();
const io = env.io();
const key = try readKeyPem(testing.allocator, io, env.key_path);
defer testing.allocator.free(key);
try testing.expectEqualStrings(fixtures.key_pem, key);
try testing.expectEqual(@as(u8, 0), key[key.len]);
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 = "key.pem", .data = at_cap });
testing.allocator.free(try readKeyPem(testing.allocator, io, env.key_path));
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 = "key.pem", .data = over });
try testing.expectError(error.TooLarge, readKeyPem(testing.allocator, io, env.key_path));
try testing.expectError(
error.Unreadable,
readKeyPem(testing.allocator, io, "./nxdns-no-such-key-9b31.pem"),
);
}
test "init fails typed on a missing file and on garbage PEM" { test "init fails typed on a missing file and on garbage PEM" {
var env: TestEnv = undefined; var env: TestEnv = undefined;
try env.init(); try env.init();
+35 -27
View File
@@ -24,13 +24,10 @@ const address = @import("../platform/address.zig");
const clients_repo = @import("../storage/repositories/clients_repo.zig"); const clients_repo = @import("../storage/repositories/clients_repo.zig");
const db = @import("../storage/db.zig"); const db = @import("../storage/db.zig");
const disk_monitor = @import("../storage/disk_monitor.zig"); const disk_monitor = @import("../storage/disk_monitor.zig");
const logger = @import("../storage/logger.zig");
const log = std.log.scoped(.clients); const log = std.log.scoped(.clients);
/// RFC 5952 text of any IPv6 address. `format` never writes more than this, so
/// the formatting in `flushOnce` cannot fail.
const max_ip_text = 45;
/// One address waiting for its row, with the wall-clock second of its most /// One address waiting for its row, with the wall-clock second of its most
/// recent query. /// recent query.
const Pending = struct { const Pending = struct {
@@ -83,16 +80,21 @@ pub const Tracker = struct {
/// Records `addr` as seen now. Called from the query path, so it writes no /// Records `addr` as seen now. Called from the query path, so it writes no
/// database and returns no error: a full table drops the address. /// database and returns no error: a full table drops the address.
/// ///
/// Returns `dropped_full` as it stands after this call. The query path
/// mirrors that counter into its own atomic, and reporting it from here
/// costs the caller nothing — the mutex is already held — where a second
/// `snapshotStats` call would take it again on every query.
///
/// `lockUncancelable` rather than `lock`: the caller is `Handler.handle`, /// `lockUncancelable` rather than `lock`: the caller is `Handler.handle`,
/// which has no error union to carry `error.Canceled` out of. The critical /// which has no error union to carry `error.Canceled` out of. The critical
/// section is a scan of at most `max_pending` addresses and holds no I/O. /// section is a scan of at most `max_pending` addresses and holds no I/O.
pub fn track(self: *Tracker, io: std.Io, addr: address.NetAddress) void { pub fn track(self: *Tracker, io: std.Io, addr: address.NetAddress) u64 {
self.trackAt(io, addr, std.Io.Clock.real.now(io).toSeconds()); return self.trackAt(io, addr, std.Io.Clock.real.now(io).toSeconds());
} }
/// `track` with the timestamp supplied, so a test does not depend on the /// `track` with the timestamp supplied, so a test does not depend on the
/// wall clock. /// wall clock.
pub fn trackAt(self: *Tracker, io: std.Io, addr: address.NetAddress, now_s: i64) void { pub fn trackAt(self: *Tracker, io: std.Io, addr: address.NetAddress, now_s: i64) u64 {
self.mutex.lockUncancelable(io); self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io); defer self.mutex.unlock(io);
@@ -100,15 +102,16 @@ pub const Tracker = struct {
if (!entry.addr.eql(addr)) continue; if (!entry.addr.eql(addr)) continue;
entry.last_seen = now_s; entry.last_seen = now_s;
self.stats.tracked += 1; self.stats.tracked += 1;
return; return self.stats.dropped_full;
} }
if (self.count == max_pending) { if (self.count == max_pending) {
self.stats.dropped_full += 1; self.stats.dropped_full += 1;
return; return self.stats.dropped_full;
} }
self.pending[self.count] = .{ .addr = addr, .last_seen = now_s }; self.pending[self.count] = .{ .addr = addr, .last_seen = now_s };
self.count += 1; self.count += 1;
self.stats.tracked += 1; self.stats.tracked += 1;
return self.stats.dropped_full;
} }
/// Flush loop, first flush one interval in: an empty table at startup has /// Flush loop, first flush one interval in: an empty table at startup has
@@ -167,7 +170,9 @@ pub const Tracker = struct {
var flushed: u64 = 0; var flushed: u64 = 0;
var failures: u64 = 0; var failures: u64 = 0;
for (batch) |entry| { for (batch) |entry| {
var buf: [max_ip_text]u8 = undefined; // `logger.max_client_len` is the RFC 5952 bound every address text
// in this program is sized by, so `format` cannot fail here.
var buf: [logger.max_client_len]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); var w: std.Io.Writer = .fixed(&buf);
entry.addr.format(&w) catch unreachable; entry.addr.format(&w) catch unreachable;
@@ -267,8 +272,9 @@ test "a client tracked twice before a flush yields one row at the later time" {
defer database.close(); defer database.close();
var tracker: Tracker = .init(30); var tracker: Tracker = .init(30);
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000); // A table with room reports no drops.
tracker.trackAt(io, parsed("192.168.1.10"), 1700000030); try testing.expectEqual(@as(u64, 0), tracker.trackAt(io, parsed("192.168.1.10"), 1700000000));
try testing.expectEqual(@as(u64, 0), tracker.trackAt(io, parsed("192.168.1.10"), 1700000030));
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io)); try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
tracker.flushOnce(io, &database, true); tracker.flushOnce(io, &database, true);
@@ -292,11 +298,11 @@ test "distinct clients each get a row and ipv6 text is canonical" {
defer database.close(); defer database.close();
var tracker: Tracker = .init(30); var tracker: Tracker = .init(30);
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000); _ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
tracker.trackAt(io, parsed("192.168.1.11"), 1700000001); _ = tracker.trackAt(io, parsed("192.168.1.11"), 1700000001);
tracker.trackAt(io, parsed("fd00:0:0:0:0:0:0:1"), 1700000002); _ = tracker.trackAt(io, parsed("fd00:0:0:0:0:0:0:1"), 1700000002);
// An IPv4-mapped literal is the same client as its plain form. // An IPv4-mapped literal is the same client as its plain form.
tracker.trackAt(io, address.NetAddress.fromIp(try std.Io.net.IpAddress.parse("::ffff:192.168.1.10", 53)), 1700000003); _ = tracker.trackAt(io, address.NetAddress.fromIp(try std.Io.net.IpAddress.parse("::ffff:192.168.1.10", 53)), 1700000003);
try testing.expectEqual(@as(u32, 3), tracker.pendingClients(io)); try testing.expectEqual(@as(u32, 3), tracker.pendingClients(io));
tracker.flushOnce(io, &database, true); tracker.flushOnce(io, &database, true);
@@ -319,13 +325,15 @@ test "a full table drops further clients and counts them" {
for (0..Tracker.max_pending) |i| { for (0..Tracker.max_pending) |i| {
var octets: [4]u8 = undefined; var octets: [4]u8 = undefined;
std.mem.writeInt(u32, &octets, @intCast(i), .big); std.mem.writeInt(u32, &octets, @intCast(i), .big);
tracker.trackAt(io, .{ .ip4 = octets }, 1700000000); _ = tracker.trackAt(io, .{ .ip4 = octets }, 1700000000);
} }
try testing.expectEqual(@as(u32, Tracker.max_pending), tracker.pendingClients(io)); try testing.expectEqual(@as(u32, Tracker.max_pending), tracker.pendingClients(io));
try testing.expectEqual(@as(u64, 0), tracker.snapshotStats(io).dropped_full); try testing.expectEqual(@as(u64, 0), tracker.snapshotStats(io).dropped_full);
tracker.trackAt(io, parsed("203.0.113.7"), 1700000000); // The return value is what the handler mirrors, so it must agree with
tracker.trackAt(io, parsed("203.0.113.8"), 1700000000); // `snapshotStats` without a second lock acquisition.
try testing.expectEqual(@as(u64, 1), tracker.trackAt(io, parsed("203.0.113.7"), 1700000000));
try testing.expectEqual(@as(u64, 2), tracker.trackAt(io, parsed("203.0.113.8"), 1700000000));
try testing.expectEqual(@as(u32, Tracker.max_pending), tracker.pendingClients(io)); try testing.expectEqual(@as(u32, Tracker.max_pending), tracker.pendingClients(io));
const stats = tracker.snapshotStats(io); const stats = tracker.snapshotStats(io);
@@ -334,12 +342,12 @@ test "a full table drops further clients and counts them" {
// A tracked client still refreshes while the table is full, and the flush // A tracked client still refreshes while the table is full, and the flush
// makes room for the next newcomer. // makes room for the next newcomer.
tracker.trackAt(io, .{ .ip4 = .{ 0, 0, 0, 0 } }, 1700000060); _ = tracker.trackAt(io, .{ .ip4 = .{ 0, 0, 0, 0 } }, 1700000060);
tracker.flushOnce(io, &database, true); tracker.flushOnce(io, &database, true);
try testing.expectEqual(@as(i64, Tracker.max_pending), try clients_repo.countClients(&database)); try testing.expectEqual(@as(i64, Tracker.max_pending), try clients_repo.countClients(&database));
try testing.expectEqual(@as(i64, 1700000060), try lastSeen(&database, "0.0.0.0")); try testing.expectEqual(@as(i64, 1700000060), try lastSeen(&database, "0.0.0.0"));
tracker.trackAt(io, parsed("203.0.113.7"), 1700000060); _ = tracker.trackAt(io, parsed("203.0.113.7"), 1700000060);
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io)); try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
} }
@@ -357,7 +365,7 @@ test "a flush touches a hand-edited row without changing what the operator set"
); );
var tracker: Tracker = .init(30); var tracker: Tracker = .init(30);
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000); _ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
tracker.flushOnce(io, &database, true); tracker.flushOnce(io, &database, true);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database)); try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
@@ -385,7 +393,7 @@ test "a gated pass writes nothing and keeps the pending clients" {
try testing.expect(!monitor.writesAllowed()); try testing.expect(!monitor.writesAllowed());
var tracker: Tracker = .init(30); var tracker: Tracker = .init(30);
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000); _ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
tracker.flushOnce(io, &database, monitor.writesAllowed()); tracker.flushOnce(io, &database, monitor.writesAllowed());
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database)); try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
@@ -413,8 +421,8 @@ test "a failing upsert counts and leaves the client to be tracked again" {
); );
var tracker: Tracker = .init(30); var tracker: Tracker = .init(30);
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000); _ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
tracker.trackAt(io, parsed("192.168.1.11"), 1700000000); _ = tracker.trackAt(io, parsed("192.168.1.11"), 1700000000);
tracker.flushOnce(io, &database, true); tracker.flushOnce(io, &database, true);
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database)); try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
@@ -424,7 +432,7 @@ test "a failing upsert counts and leaves the client to be tracked again" {
try testing.expectEqual(@as(u32, 0), tracker.pendingClients(io)); try testing.expectEqual(@as(u32, 0), tracker.pendingClients(io));
try database.exec("DROP TRIGGER refuse_insert;"); try database.exec("DROP TRIGGER refuse_insert;");
tracker.trackAt(io, parsed("192.168.1.10"), 1700000060); _ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000060);
tracker.flushOnce(io, &database, true); tracker.flushOnce(io, &database, true);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database)); try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
} }
@@ -486,7 +494,7 @@ test "the run loop flushes on its interval and returns on cancel" {
defer database.close(); defer database.close();
var tracker: Tracker = .init(30); var tracker: Tracker = .init(30);
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000); _ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
var future = try io.concurrent(Tracker.run, .{ var future = try io.concurrent(Tracker.run, .{
&tracker, &tracker,
+10 -16
View File
@@ -62,24 +62,19 @@ pub const udp_limit_max: u16 = 4096;
pub const max_cname_depth = 8; pub const max_cname_depth = 8;
/// `"cname:"` plus the longest `matcher.Reason` tag, which the comptime block /// `"cname:"` plus the longest `matcher.Reason` tag, which the comptime block
/// below proves fits the 32 bytes `logger.Entry` stores. /// below proves fits what `logger.Entry` stores.
const cname_reason_prefix = "cname:"; const cname_reason_prefix = "cname:";
const max_reason_len = 32;
comptime { comptime {
for (std.enums.values(matcher.Reason)) |reason| { for (std.enums.values(matcher.Reason)) |reason| {
if (cname_reason_prefix.len + @tagName(reason).len > max_reason_len) { if (cname_reason_prefix.len + @tagName(reason).len > logger_mod.max_reason_len) {
@compileError("a matcher.Reason tag no longer fits the query log's reason field"); @compileError("a matcher.Reason tag no longer fits the query log's reason field");
} }
} }
} }
/// RFC 5952 text of any IPv6 address. `logger.Entry` truncates at the same
/// width, so nothing an address formats to is ever cut.
const max_ip_text = 45;
/// `udp://` or `tcp://`, an IPv6 literal in brackets, and a port. /// `udp://` or `tcp://`, an IPv6 literal in brackets, and a port.
const max_resolver_text = "tcp://[".len + max_ip_text + "]:65535".len; const max_resolver_text = "tcp://[".len + logger_mod.max_client_len + "]:65535".len;
/// Ruling 20 leaves the pool's answering endpoint out of reach: the pool tracks /// Ruling 20 leaves the pool's answering endpoint out of reach: the pool tracks
/// it, but reading it back would mean new plumbing through `transport.Client` /// it, but reading it back would mean new plumbing through `transport.Client`
@@ -157,9 +152,9 @@ pub const Handler = struct {
unfiltered_queries: std.atomic.Value(u64) = .init(0), unfiltered_queries: std.atomic.Value(u64) = .init(0),
safesearch_rewrites: std.atomic.Value(u64) = .init(0), safesearch_rewrites: std.atomic.Value(u64) = .init(0),
ecs_strip_failed: std.atomic.Value(u64) = .init(0), ecs_strip_failed: std.atomic.Value(u64) = .init(0),
/// Mirrors the tracker's own `dropped_full`, refreshed on every query: /// Mirrors the tracker's own `dropped_full`, refreshed on every query
/// `Tracker.track` reports nothing back, and a counter that only the /// from what `Tracker.track` returns: a counter that only the tracker
/// tracker holds would not appear beside the rest of these. /// holds would not appear beside the rest of these.
tracker_full: std.atomic.Value(u64) = .init(0), tracker_full: std.atomic.Value(u64) = .init(0),
}; };
@@ -276,8 +271,7 @@ pub const Handler = struct {
const started = std.Io.Clock.real.now(io); const started = std.Io.Clock.real.now(io);
if (self.tracker) |tracker| { if (self.tracker) |tracker| {
tracker.track(io, from); self.stats.tracker_full.store(tracker.track(io, from), .monotonic);
self.stats.tracker_full.store(tracker.snapshotStats(io).dropped_full, .monotonic);
} }
// Ruling 4: no snapshot means no group and no filtering, and the query // Ruling 4: no snapshot means no group and no filtering, and the query
@@ -501,7 +495,7 @@ const Context = struct {
bump(if (uncloaked) &ctx.handler.stats.uncloak_blocked else &ctx.handler.stats.blocked); bump(if (uncloaked) &ctx.handler.stats.uncloak_blocked else &ctx.handler.stats.blocked);
var reason_buf: [max_reason_len]u8 = undefined; var reason_buf: [logger_mod.max_reason_len]u8 = undefined;
return ctx.reply(bytes, .{ return ctx.reply(bytes, .{
.blocked = true, .blocked = true,
.block_reason = blockReason(&reason_buf, reason, uncloaked), .block_reason = blockReason(&reason_buf, reason, uncloaked),
@@ -541,7 +535,7 @@ const Context = struct {
fn log(ctx: *Context, fields: LogFields) void { fn log(ctx: *Context, fields: LogFields) void {
const sink = ctx.handler.sink orelse return; const sink = ctx.handler.sink orelse return;
var ip_buf: [max_ip_text]u8 = undefined; var ip_buf: [logger_mod.max_client_len]u8 = undefined;
var w: std.Io.Writer = .fixed(&ip_buf); var w: std.Io.Writer = .fixed(&ip_buf);
ctx.from.format(&w) catch unreachable; ctx.from.format(&w) catch unreachable;
@@ -819,7 +813,7 @@ fn cnameTarget(p: packet.Packet, owner: name.Name) ?name.Name {
/// The `block_reason` column. An uncloaked block names the level that matched /// The `block_reason` column. An uncloaked block names the level that matched
/// the CNAME target, prefixed so that it is not mistaken for a decision about /// the CNAME target, prefixed so that it is not mistaken for a decision about
/// the name the client asked for. /// the name the client asked for.
fn blockReason(buf: *[max_reason_len]u8, reason: matcher.Reason, uncloaked: bool) []const u8 { fn blockReason(buf: *[logger_mod.max_reason_len]u8, reason: matcher.Reason, uncloaked: bool) []const u8 {
const tag = @tagName(reason); const tag = @tagName(reason);
if (!uncloaked) return tag; if (!uncloaked) return tag;
@memcpy(buf[0..cname_reason_prefix.len], cname_reason_prefix); @memcpy(buf[0..cname_reason_prefix.len], cname_reason_prefix);
+2 -2
View File
@@ -268,8 +268,8 @@ pub const Db = struct {
/// `verifyImmutable` to compare against when it ends. /// `verifyImmutable` to compare against when it ends.
immutable_guard: ?ImmutableGuard = null, immutable_guard: ?ImmutableGuard = null,
/// Every mode carries `FULLMUTEX` (serialized mode). Phase 6's query logger /// Every mode carries `FULLMUTEX` (serialized mode). The query logger and
/// and Phase 8's API handlers share one handle across `std.Io` tasks, and a /// the web API handlers share one handle across `std.Io` tasks, and a
/// per-handle mutex inside SQLite is cheaper to be correct about than a /// per-handle mutex inside SQLite is cheaper to be correct about than a
/// hand-rolled one; `config.db` write volume is negligible. `EXRESCODE` /// hand-rolled one; `config.db` write volume is negligible. `EXRESCODE`
/// makes `sqlite3_extended_errcode` meaningful from the first call. /// makes `sqlite3_extended_errcode` meaningful from the first call.
+2 -1
View File
@@ -4,7 +4,8 @@
//! //!
//! The state is the gate other components read before a non-essential write: //! The state is the gate other components read before a non-essential write:
//! the query logger holds its batches while `writesAllowed` is false, and //! the query logger holds its batches while `writesAllowed` is false, and
//! Phase 7 gates blocklist updates the same way. Nothing here edits a //! the blocklist scheduler gates its refresh passes the same way
//! (`filter/manager.zig`'s `refreshGated`). Nothing here edits a
//! milestone-5 file; the gate is pulled, not pushed. //! milestone-5 file; the gate is pulled, not pushed.
const std = @import("std"); const std = @import("std");
+12 -7
View File
@@ -41,11 +41,14 @@ pub const hidden_marker = "hidden";
/// How long a batch waits before it re-reads the disk monitor. /// How long a batch waits before it re-reads the disk monitor.
pub const gate_retry_s = 1; pub const gate_retry_s = 1;
const max_domain_len = 253; /// The widths of the query log's text columns, and the single source of them:
/// every producer that formats into one of these fields sizes its own buffer
/// from the constant here, so nothing can format wider than the row stores.
pub const max_domain_len = 253;
/// RFC 5952 text of any IPv6 address, zone identifier included. /// RFC 5952 text of any IPv6 address, zone identifier included.
const max_client_len = 45; pub const max_client_len = 45;
const max_reason_len = 32; pub const max_reason_len = 32;
const max_upstream_len = 64; pub const max_upstream_len = 64;
/// One row on its way to `query_log`, carrying its own bytes. /// One row on its way to `query_log`, carrying its own bytes.
pub const Entry = struct { pub const Entry = struct {
@@ -175,8 +178,9 @@ pub const Logger = struct {
/// that sees this must not expect rows. /// that sees this must not expect rows.
writer_failed: std.atomic.Value(bool), writer_failed: std.atomic.Value(bool),
/// `queue_buf.len` is the backpressure cap — Phase 7 passes /// `queue_buf.len` is the backpressure cap — the composition root
/// `cfg.query_log_buffer_max` entries. The queue holds waiting tasks in /// (`app.zig:311`) allocates `cfg.logging.query_log_buffer_max` entries,
/// which `config/validate.zig` bounds. The queue holds waiting tasks in
/// intrusive lists, so a `Logger` must not be moved once anything has /// intrusive lists, so a `Logger` must not be moved once anything has
/// touched it. /// touched it.
pub fn init(cfg: model.Logging, queue_buf: []Entry) Logger { pub fn init(cfg: model.Logging, queue_buf: []Entry) Logger {
@@ -300,7 +304,8 @@ pub const Logger = struct {
/// it has flushed what was left. /// it has flushed what was left.
/// ///
/// A writer held by the disk gate keeps holding: it flushes when the disk /// A writer held by the disk gate keeps holding: it flushes when the disk
/// recovers, and Phase 7 cancels the task if it will not wait. A canceled /// recovers, and the `group.cancel` that follows this call in `app.zig`
/// stops a writer that will not wait. A canceled
/// writer counts the batch it holds under `queries_dropped`. /// writer counts the batch it holds under `queries_dropped`.
pub fn shutdown(self: *Logger, io: std.Io) void { pub fn shutdown(self: *Logger, io: std.Io) void {
self.queue.close(io); self.queue.close(io);
+1 -1
View File
@@ -68,7 +68,7 @@ pub const RecreateReason = enum { missing, corrupt, not_a_database, quick_check_
pub const OpenResult = struct { pub const OpenResult = struct {
database: db.Db, database: db.Db,
/// Non-null feeds a counter and the `/api/health` rollup in Phase 8. /// Non-null feeds a counter and the `/api/health` rollup.
recreated: ?RecreateReason, recreated: ?RecreateReason,
}; };
+2 -2
View File
@@ -9,8 +9,8 @@
//! a test helper — it deliberately does not answer that question. //! a test helper — it deliberately does not answer that question.
//! //!
//! The import path is list / insert / deleteAll / count, plus the two runtime //! The import path is list / insert / deleteAll / count, plus the two runtime
//! calls `upsertSeen` and `pruneStale` that the Phase 7 client tracker owns. //! calls `upsertSeen` and `pruneStale` that `server/clients.zig`'s tracker owns.
//! Phase 8's REST surface is the third section: it speaks row ids and shows //! The REST surface is the third section: it speaks row ids and shows
//! every client, materialised ones included. //! every client, materialised ones included.
const std = @import("std"); const std = @import("std");
+1 -1
View File
@@ -4,7 +4,7 @@
//! stable across an import, so an export carrying them would not re-import into //! stable across an import, so an export carrying them would not re-import into
//! the same shape. //! the same shape.
//! //!
//! The import path is list / insert / deleteAll / count. Phase 8's REST surface //! The import path is list / insert / deleteAll / count. The REST surface
//! is the second half of this file: it speaks row ids, because that is what a //! is the second half of this file: it speaks row ids, because that is what a
//! `/api/groups/{id}` request names. //! `/api/groups/{id}` request names.
+1 -1
View File
@@ -1,6 +1,6 @@
//! `local_records` and `forward_zones`. //! `local_records` and `forward_zones`.
//! //!
//! The import path is list / insert / deleteAll / count. Phase 8's REST surface //! The import path is list / insert / deleteAll / count. The REST surface
//! follows each table's section: it speaks row ids, because that is what an //! follows each table's section: it speaks row ids, because that is what an
//! `/api/local-records/{id}` or `/api/forward-zones/{id}` request names. //! `/api/local-records/{id}` or `/api/forward-zones/{id}` request names.
+1 -1
View File
@@ -12,7 +12,7 @@
//! order as a whole is: `import` inserts the rules in export order, so the new //! order as a whole is: `import` inserts the rules in export order, so the new
//! ids ascend in exactly the order this statement produced. //! ids ascend in exactly the order this statement produced.
//! //!
//! The import path is list / insert / deleteAll / count. Phase 8's REST surface //! The import path is list / insert / deleteAll / count. The REST surface
//! is the second half of this file: it speaks row ids, because that is what an //! is the second half of this file: it speaks row ids, because that is what an
//! `/api/rules/{id}` request names. //! `/api/rules/{id}` request names.
+1 -1
View File
@@ -6,7 +6,7 @@
//! defaults so two exports taken minutes apart stay identical. //! defaults so two exports taken minutes apart stay identical.
//! //!
//! The import path is list / insert / deleteAll / count; the runtime columns and //! The import path is list / insert / deleteAll / count; the runtime columns and
//! Phase 8's REST surface follow it, both keyed by row id. //! the REST surface follow it, both keyed by row id.
const std = @import("std"); const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
+1 -1
View File
@@ -4,7 +4,7 @@
//! meaningful order — it matches what `Pool.init` expects — and `url` breaks //! meaningful order — it matches what `Pool.init` expects — and `url` breaks
//! ties uniquely, which is what makes an export byte-stable. //! ties uniquely, which is what makes an export byte-stable.
//! //!
//! The import path is list / insert / deleteAll / count. Phase 8's REST surface //! The import path is list / insert / deleteAll / count. The REST surface
//! is the second half of this file: it speaks row ids, because that is what an //! is the second half of this file: it speaks row ids, because that is what an
//! `/api/upstreams/{id}` request names. //! `/api/upstreams/{id}` request names.
+6 -4
View File
@@ -4,7 +4,7 @@
//! //!
//! The pass touches `querylog.db` only. §3.6 walls `config.db` off from //! The pass touches `querylog.db` only. §3.6 walls `config.db` off from
//! retention churn, and the `hand_edited=0` client rows of §7.2 are pruned by //! retention churn, and the `hand_edited=0` client rows of §7.2 are pruned by
//! whatever creates them, which is Phase 7. //! whatever creates them, which is the client tracker (`server/clients.zig`).
//! //!
//! Nothing here retries within a pass. A failed step logs at `warn` and the //! Nothing here retries within a pass. A failed step logs at `warn` and the
//! next pass, a day later, does the same work again against the same data. //! next pass, a day later, does the same work again against the same data.
@@ -134,7 +134,8 @@ pub const Retention = struct {
_ = counter.fetchAdd(delta, .monotonic); _ = counter.fetchAdd(delta, .monotonic);
} }
/// Daily loop, first pass immediately. Phase 7 starts it. /// Daily loop, first pass immediately. The composition root starts it as a
/// concurrent task (`app.zig`).
/// ///
/// `boot` rather than `awake`: a box that suspends overnight must still see /// `boot` rather than `awake`: a box that suspends overnight must still see
/// its day elapse. /// its day elapse.
@@ -148,8 +149,9 @@ pub const Retention = struct {
/// with the batch, and a checkpoint or a `VACUUM` can land inside a /// with the batch, and a checkpoint or a `VACUUM` can land inside a
/// transaction that is still open. /// transaction that is still open.
/// ///
/// Retention takes `database` per call and opens nothing itself; Phase 7 /// Retention takes `database` per call and opens nothing itself; the
/// opens the second connection. Isolation across the two connections is /// composition root opens the second connection with
/// `cli.DataDir.reopenQuerylogDb`. Isolation across the two connections is
/// SQLite's own — WAL plus the `busy_timeout` of `db.zig`'s open options — /// SQLite's own — WAL plus the `busy_timeout` of `db.zig`'s open options —
/// so a pass that still loses a race sees `error.Busy` or `error.Locked`, /// so a pass that still loses a race sees `error.Busy` or `error.Locked`,
/// logs at `warn`, and repeats the work on the next interval. /// logs at `warn`, and repeats the work on the next interval.
+61 -6
View File
@@ -162,11 +162,34 @@ pub const DohClient = struct {
/// time two phases shared an error. /// time two phases shared an error.
const Phase = enum { connect, send, receive }; const Phase = enum { connect, send, receive };
/// Every TLS failure this client can reach, named rather than matched by
/// prefix. Two groups:
///
/// - `std.http.Client.RequestError` collapses every handshake and bundle fault
/// into `TlsInitializationFailed` / `CertificateBundleLoadFailure`
/// (Client.zig:1470, :1717).
/// - `std.crypto.tls.Client.ReadError` is what `headCause`/`bodyCause` unwrap
/// out of a collapsed `error.ReadFailed`, through
/// `Connection.getReadError` (Client.zig:392), so its record-layer members
/// arrive here as themselves. Without them a decode error or a bad record MAC
/// would be reported as a plain receive failure.
fn mapError(err: anyerror, phase: Phase) transport.ExchangeError { fn mapError(err: anyerror, phase: Phase) transport.ExchangeError {
if (transport.mapLocal(err)) |local| return local; if (transport.mapLocal(err)) |local| return local;
const err_name = @errorName(err); switch (err) {
if (std.mem.startsWith(u8, err_name, "Tls") or error.TlsInitializationFailed,
std.mem.startsWith(u8, err_name, "Certificate")) return error.TlsFailed; error.CertificateBundleLoadFailure,
error.TlsAlert,
error.TlsBadLength,
error.TlsBadRecordMac,
error.TlsConnectionTruncated,
error.TlsDecodeError,
error.TlsRecordOverflow,
error.TlsUnexpectedMessage,
error.TlsIllegalParameter,
error.TlsSequenceOverflow,
=> return error.TlsFailed,
else => {},
}
return switch (phase) { return switch (phase) {
.connect => error.ConnectFailed, .connect => error.ConnectFailed,
.send => error.SendFailed, .send => error.SendFailed,
@@ -174,6 +197,27 @@ fn mapError(err: anyerror, phase: Phase) transport.ExchangeError {
}; };
} }
// `error.X` in an expression names a member into existence rather than
// referring to one, so the switch above would keep compiling — and silently
// stop matching — if std renamed any of these. This block is what fails the
// build instead. Every member of the read-cause set must be classified, and the
// two collapsed names must still exist.
comptime {
for (@typeInfo(std.crypto.tls.Client.ReadError).error_set.?) |member| {
const value: anyerror = @field(std.crypto.tls.Client.ReadError, member.name);
if (mapError(value, .receive) != error.TlsFailed) {
@compileError("unclassified TLS read cause: " ++ member.name);
}
}
for ([_][]const u8{ "TlsInitializationFailed", "CertificateBundleLoadFailure" }) |name| {
var found = false;
for (@typeInfo(std.http.Client.RequestError).error_set.?) |member| {
if (std.mem.eql(u8, member.name, name)) found = true;
}
if (!found) @compileError("std.http.Client.RequestError no longer names " ++ name);
}
}
const Connection = std.http.Client.Connection; const Connection = std.http.Client.Connection;
const Request = std.http.Client.Request; const Request = std.http.Client.Request;
const Response = std.http.Client.Response; const Response = std.http.Client.Response;
@@ -307,10 +351,21 @@ test "mapError maps local errors before phase errors" {
try testing.expectEqual(error.Unexpected, mapError(error.Unexpected, .send)); try testing.expectEqual(error.Unexpected, mapError(error.Unexpected, .send));
} }
test "mapError maps tls errors regardless of phase" { test "mapError maps the collapsed tls errors regardless of phase" {
try testing.expectEqual(error.TlsFailed, mapError(error.TlsInitializationFailed, .connect)); try testing.expectEqual(error.TlsFailed, mapError(error.TlsInitializationFailed, .connect));
try testing.expectEqual(error.TlsFailed, mapError(error.TlsAlert, .receive)); try testing.expectEqual(error.TlsFailed, mapError(error.TlsInitializationFailed, .receive));
try testing.expectEqual(error.TlsFailed, mapError(error.CertificateExpired, .connect)); try testing.expectEqual(error.TlsFailed, mapError(error.CertificateBundleLoadFailure, .connect));
}
test "mapError maps every unwrapped record-layer cause to TlsFailed" {
// The set is the one `Connection.getReadError` can hand back, so the loop
// fails the day std adds a member the switch does not name.
inline for (@typeInfo(std.crypto.tls.Client.ReadError).error_set.?) |member| {
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
mapError(@field(std.crypto.tls.Client.ReadError, member.name), .receive),
);
}
} }
test "mapError maps remaining errors by phase" { test "mapError maps remaining errors by phase" {
+2 -2
View File
@@ -116,13 +116,13 @@ pub fn login(state: *server.WebState, io: std.Io, request: *Request) HandlerErro
// Ruling 18: a refused login is a 401, not the 400 an invalid value // Ruling 18: a refused login is a 401, not the 400 an invalid value
// would earn elsewhere. Only the address and the outcome are logged. // would earn elsewhere. Only the address and the outcome are logged.
if (failure == .invalid) { if (failure == .invalid) {
log.warn("web login refused for {f}", .{request.peer}); log.warn("web login refused for {f}", .{request.client_addr});
return http_util.respondError(request, .unauthorized, "invalid password"); return http_util.respondError(request, .unauthorized, "invalid password");
} }
return mutations.respondFailure(request, failure, "verifying the web password"); return mutations.respondFailure(request, failure, "verifying the web password");
}, },
.cookie => |cookie| { .cookie => |cookie| {
log.info("web login accepted for {f}", .{request.peer}); log.info("web login accepted for {f}", .{request.client_addr});
var buf: [cookie_buf_len]u8 = undefined; var buf: [cookie_buf_len]u8 = undefined;
const header = http_util.formatSetCookie( const header = http_util.formatSetCookie(
&buf, &buf,
+14
View File
@@ -259,6 +259,20 @@ fn expectType(comptime name: []const u8, comptime Actual: type, comptime Expecte
/// ///
/// A state with no `reload_fn` has nothing to reload — that is the shape of a /// A state with no `reload_fn` has nothing to reload — that is the shape of a
/// web layer under test, and of one whose composition root wired no manager. /// web layer under test, and of one whose composition root wired no manager.
///
/// Locking contract, and it is the reason every one of the fourteen call sites
/// in `groups.zig`, `rules.zig`, `clients.zig` and `blocklists.zig` may call
/// this *after* releasing `state.config_lock`: `reload_fn` must re-read all of
/// the state it publishes from the database itself, under the manager's own
/// writer lock. It must never accept rows the caller read. Rows read under
/// `config_lock` and passed across its release are already stale, so a
/// signature change that adds a row parameter here silently breaks the
/// correctness of all fourteen sites — every one of them would have to move
/// the call back inside the lock.
///
/// `swapLocalTables` below is the pre-read shape and is exactly the contrast:
/// it takes the rows, so `local.zig`'s `publish` calls it while it still holds
/// `config_lock`, and the ordering rationale lives on that function.
pub fn reload(state: *server.WebState, io: std.Io) ?Failure { pub fn reload(state: *server.WebState, io: std.Io) ?Failure {
const reload_fn = state.reload_fn orelse return null; const reload_fn = state.reload_fn orelse return null;
reload_fn(state, io) catch |err| { reload_fn(state, io) catch |err| {
+5 -5
View File
@@ -15,6 +15,7 @@ const Allocator = std.mem.Allocator;
const db = @import("../../storage/db.zig"); const db = @import("../../storage/db.zig");
const http_util = @import("../http_util.zig"); const http_util = @import("../http_util.zig");
const logger = @import("../../storage/logger.zig");
const queries_repo = @import("../../storage/repositories/queries_repo.zig"); const queries_repo = @import("../../storage/repositories/queries_repo.zig");
const server = @import("../server.zig"); const server = @import("../server.zig");
@@ -23,11 +24,10 @@ const log = std.log.scoped(.web_queries);
pub const default_limit: u32 = 100; pub const default_limit: u32 = 100;
pub const max_limit: u32 = queries_repo.max_limit; pub const max_limit: u32 = queries_repo.max_limit;
/// A domain filter longer than the longest legal domain name matches nothing. /// The filter widths are the stored widths: a filter wider than the column it
pub const max_domain_len = 253; /// compares against could only match a row the query log cannot hold.
pub const max_domain_len = logger.max_domain_len;
/// Long enough for an IPv6 address with a zone identifier. pub const max_client_len = logger.max_client_len;
pub const max_client_len = 64;
/// Where the two string filters are copied to. The parsed filter borrows them, /// Where the two string filters are copied to. The parsed filter borrows them,
/// so it must not outlive the buffers — in the handler both live in the same /// so it must not outlive the buffers — in the handler both live in the same
+46 -14
View File
@@ -332,10 +332,10 @@ pub fn applyPut(
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, cfg.web.password_hash); const hash_changed = password != null and !std.mem.eql(u8, previous_hash, cfg.web.password_hash);
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, cfg.web.password_hash) else null; const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, cfg.web.password_hash) else null;
if (writeSettings(arena, database, cfg)) |err| { writeSettings(arena, database, cfg) catch |err| {
if (replacement) |hash| state.gpa.free(hash); if (replacement) |hash| state.gpa.free(hash);
return .{ .fail = .{ .internal = err } }; return .{ .fail = .{ .internal = err } };
} };
if (replacement) |hash| { if (replacement) |hash| {
// Ruling 17, both halves: the running server must verify against the // Ruling 17, both halves: the running server must verify against the
@@ -354,26 +354,36 @@ pub fn applyPut(
/// costs a few dozen upserts and buys the guarantee that the table is exactly /// costs a few dozen upserts and buys the guarantee that the table is exactly
/// what `model.toSettings` says the merged configuration is — no key can be /// what `model.toSettings` says the merged configuration is — no key can be
/// missed and none can be left behind. /// missed and none can be left behind.
fn writeSettings(arena: Allocator, database: *db.Db, cfg: model.Config) ?db.Error { fn writeSettings(arena: Allocator, database: *db.Db, cfg: model.Config) db.Error!void {
var pairs: std.ArrayList(model.SettingPair) = .empty; var pairs: std.ArrayList(model.SettingPair) = .empty;
model.toSettings(cfg, arena, &pairs) catch return error.OutOfMemory; try model.toSettings(cfg, arena, &pairs);
var tx = db.Tx.begin(database) catch |err| return err; var tx = try db.Tx.begin(database);
errdefer tx.rollback(); errdefer tx.rollback();
try write_fault.check();
for (pairs.items) |pair| { for (pairs.items) |pair| {
settings_repo.putSetting(database, pair.key, pair.value) catch |err| { try settings_repo.putSetting(database, pair.key, pair.value);
tx.rollback();
return err;
};
} }
tx.commit() catch |err| { try tx.commit();
tx.rollback();
return err;
};
return null;
} }
/// Fails one `writeSettings` after its transaction has begun, so a test can
/// prove the `errdefer` above rolls that transaction back rather than leaving
/// the shared connection inside it. Test builds only, and it reduces to nothing
/// everywhere else — the rotation seam's shape (logging.zig).
const write_fault = if (builtin.is_test) struct {
var armed: bool = false;
fn check() db.Error!void {
if (!armed) return;
armed = false;
return error.Internal;
}
} else struct {
fn check() db.Error!void {}
};
fn problem(arena: Allocator, cfg: model.Config) error{OutOfMemory}!?[]const u8 { fn problem(arena: Allocator, cfg: model.Config) error{OutOfMemory}!?[]const u8 {
return mutations.firstProblem(arena, cfg); return mutations.firstProblem(arena, cfg);
} }
@@ -581,6 +591,28 @@ test "a put that would not validate writes nothing" {
try testing.expectEqual(@as(u16, 53), stored.dns.port); try testing.expectEqual(@as(u16, 53), stored.dns.port);
} }
test "a write that fails after the transaction begins rolls it back" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try seeded(&bench);
var patch: Patch = .{};
patch.dns = .{ .port = 5353 };
write_fault.armed = true;
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
try testing.expect(outcome.fail == .internal);
// BEGIN IMMEDIATE inside an open transaction is an error, so a second
// `begin` succeeding is what proves the errdefer ran.
var tx = try db.Tx.begin(&bench.database);
tx.rollback();
const stored = try mutations.loadConfig(bench.arena(), &bench.database);
try testing.expectEqual(@as(u16, 53), stored.dns.port);
}
test "an enum value the model does not know names the key it came from" { test "an enum value the model does not know names the key it came from" {
var bench: mutations.Bench = undefined; var bench: mutations.Bench = undefined;
try bench.init(testing.allocator); try bench.init(testing.allocator);
+4 -1
View File
@@ -349,7 +349,10 @@ pub fn respondBytes(
) HandlerError!void { ) HandlerError!void {
var headers: [8]http.Header = undefined; var headers: [8]http.Header = undefined;
headers[0] = .{ .name = "content-type", .value = content_type }; headers[0] = .{ .name = "content-type", .value = content_type };
if (extra_headers.len + 1 > headers.len) return error.OutOfMemory; // Every call site passes a comptime-known list, the longest of them three
// headers, so overflowing this array is a programmer error and never a
// condition a request can produce.
std.debug.assert(extra_headers.len + 1 <= headers.len);
@memcpy(headers[1 .. 1 + extra_headers.len], extra_headers); @memcpy(headers[1 .. 1 + extra_headers.len], extra_headers);
return request.http.respond(body, .{ return request.http.respond(body, .{
.status = status, .status = status,
+10 -2
View File
@@ -728,8 +728,16 @@ test "every HELP line has a TYPE line and a sample, and every sample a name" {
} }
try testing.expectEqual(helps, types); try testing.expectEqual(helps, types);
try testing.expectEqual(helps, samples); try testing.expectEqual(helps, samples);
// `nxdns_up` plus every DNS counter: the families a bare state still has. // The families a bare state still has: `nxdns_up`, every DNS counter, the
try testing.expectEqual(1 + dns_stat_fields.len + 7, samples); // query log writer's, and the diagnostic log sink's. Derived rather than
// counted, so a new counter in any of those structs extends the exposition
// and this assertion together.
try testing.expectEqual(
1 + dns_stat_fields.len +
@typeInfo(LoggerCounters).@"struct".fields.len +
@typeInfo(logging.Stats).@"struct".fields.len,
samples,
);
// The reflective walk names the counters, so a renamed `Handler.Stats` // The reflective walk names the counters, so a renamed `Handler.Stats`
// field silently renames a scraped series. Pin the ones an operator alerts // field silently renames a scraped series. Pin the ones an operator alerts
// on by name. // on by name.
+3
View File
@@ -156,6 +156,9 @@ pub const WebState = struct {
querylog_db: ?*db.Db = null, querylog_db: ?*db.Db = null,
version: []const u8 = "", version: []const u8 = "",
/// The `--web-dev` asset directory, read by the dev-mode fallback. Empty
/// whenever that fallback is not wired.
dev_dir: []const u8 = "",
/// Unix seconds at process start, for uptime. /// Unix seconds at process start, for uptime.
started_unix: i64 = 0, started_unix: i64 = 0,
+13
View File
@@ -434,6 +434,19 @@ test "the embedded dist has an index and consistent gzip siblings" {
// The gzip member header: build-time compression, not an accident. // The gzip member header: build-time compression, not an accident.
try testing.expectEqual(@as(u8, 0x1f), file.bytes[0]); try testing.expectEqual(@as(u8, 0x1f), file.bytes[0]);
try testing.expectEqual(@as(u8, 0x8b), file.bytes[1]); try testing.expectEqual(@as(u8, 0x8b), file.bytes[1]);
// A sibling is served under its base file's identity, so equal
// content is the only thing that makes the substitution honest.
var input: std.Io.Reader = .fixed(file.bytes);
const window = try testing.allocator.alloc(u8, std.compress.flate.max_window_len);
defer testing.allocator.free(window);
var decompress: std.compress.flate.Decompress = .init(&input, .gzip, window);
const plain = try decompress.reader.allocRemaining(
testing.allocator,
.limited(max_disk_asset_bytes),
);
defer testing.allocator.free(plain);
try testing.expectEqualSlices(u8, base.bytes, plain);
} }
} }
} }
+1 -3
View File
@@ -89,9 +89,7 @@ fn parseTarget(_: void, smith: *Smith) anyerror!void {
// `parseOpt` validates the whole option list, so an `OptRecord` it // `parseOpt` validates the whole option list, so an `OptRecord` it
// returned must iterate to the end without error. // returned must iterate to the end without error.
var options = edns.options(bytes, opt); var options = edns.options(bytes, opt);
while (try options.next()) |option| { while (try options.next()) |option| std.mem.doNotOptimizeAway(option.data);
if (option.code == edns.ecs_option_code) ignore(edns.parseEcs(option.data));
}
ignore(edns.findOption(bytes, opt, edns.ecs_option_code)); ignore(edns.findOption(bytes, opt, edns.ecs_option_code));
} }
+39 -7
View File
@@ -4,7 +4,9 @@
//! writes into `<out-dir>`: //! writes into `<out-dir>`:
//! //!
//! - `<name>.gz` next to each asset worth compressing, unless the dist already //! - `<name>.gz` next to each asset worth compressing, unless the dist already
//! ships one (a Vite plugin may pre-compress). Compression happens here, at //! ships one (a Vite plugin may pre-compress), in which case the shipped
//! sibling is decompressed and byte-compared against its base file, and a
//! `.gz` with no base file at all is rejected. Compression happens here, at
//! build time, because `flate.Compress` needs a 64 KiB window per stream — //! build time, because `flate.Compress` needs a 64 KiB window per stream —
//! a cost the server must not pay per request for immutable content. //! a cost the server must not pay per request for immutable content.
//! - `assets.zig`, the module index the server embeds: one entry per servable //! - `assets.zig`, the module index the server embeds: one entry per servable
@@ -53,7 +55,12 @@ pub fn main(init: std.process.Init) !void {
var assets: std.ArrayList(Asset) = .empty; var assets: std.ArrayList(Asset) = .empty;
for (names) |name| { for (names) |name| {
if (std.mem.endsWith(u8, name, ".gz") and contains(names, name[0 .. name.len - 3])) { if (std.mem.endsWith(u8, name, ".gz")) {
if (!contains(names, name[0 .. name.len - 3])) {
// The server only ever reaches a `.gz` through its base file's
// entry, so an orphan is unservable bytes in the binary.
std.process.fatal("'{s}' has no base file; nothing can serve it", .{name});
}
// A pre-compressed sibling; indexed alongside its base file below. // A pre-compressed sibling; indexed alongside its base file below.
continue; continue;
} }
@@ -69,12 +76,13 @@ pub fn main(init: std.process.Init) !void {
}); });
const sibling = try std.fmt.allocPrint(arena, "{s}.gz", .{name}); const sibling = try std.fmt.allocPrint(arena, "{s}.gz", .{name});
const gz = if (contains(names, sibling)) const gz = if (contains(names, sibling)) shipped: {
dist.readFileAlloc(io, sibling, arena, .limited(max_asset_bytes)) catch |err| { const shipped = dist.readFileAlloc(io, sibling, arena, .limited(max_asset_bytes)) catch |err| {
std.process.fatal("cannot read '{s}': {t}", .{ sibling, err }); std.process.fatal("cannot read '{s}': {t}", .{ sibling, err });
} };
else try verifyShipped(arena, sibling, shipped, bytes);
try compressWorthwhile(arena, io, out, sibling, bytes) orelse continue; break :shipped shipped;
} else try compressWorthwhile(arena, io, out, sibling, bytes) orelse continue;
try assets.append(arena, .{ try assets.append(arena, .{
.path = try std.fmt.allocPrint(arena, "/{s}", .{sibling}), .path = try std.fmt.allocPrint(arena, "/{s}", .{sibling}),
@@ -116,6 +124,30 @@ fn contains(sorted: []const []const u8, name: []const u8) bool {
return false; return false;
} }
/// Fatal unless `gz` decompresses to exactly `base`.
///
/// The server answers a compressed request with the sibling's bytes under the
/// *base* file's identity, so a sibling the dist shipped from a stale or
/// truncated build would hand every client the wrong content behind a correct
/// ETag. Nothing downstream can catch that: gzip carries a CRC of whatever it
/// was compressed from, not of what it was supposed to be.
fn verifyShipped(arena: Allocator, sub_path: []const u8, gz: []const u8, base: []const u8) !void {
var input: std.Io.Reader = .fixed(gz);
const window = try arena.alloc(u8, std.compress.flate.max_window_len);
var decompress: std.compress.flate.Decompress = .init(&input, .gzip, window);
const plain = decompress.reader.allocRemaining(arena, .limited(max_asset_bytes)) catch |err| {
// `Reader` collapses every decode failure into `ReadFailed` and stashes
// the real one; the stashed name is what identifies the bad file.
std.process.fatal("'{s}' is not readable gzip: {t}", .{ sub_path, decompress.err orelse err });
};
if (!std.mem.eql(u8, plain, base)) {
std.process.fatal(
"'{s}' does not decompress to its base file ({d} bytes out, {d} expected)",
.{ sub_path, plain.len, base.len },
);
}
}
/// Gzips `bytes`; writes and returns the result only when it is smaller than /// Gzips `bytes`; writes and returns the result only when it is smaller than
/// the original, else null. Equal-or-larger output means the asset is already /// the original, else null. Equal-or-larger output means the asset is already
/// compressed (an image, a font) and the sibling would waste binary size. /// compressed (an image, a font) and the sibling would waste binary size.
@@ -0,0 +1,29 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { ApiError } from "@/lib/api";
import BlocklistForm, { swallowMutationError } from "./BlocklistForm";
test("swallowMutationError drops an ApiError and rethrows anything else", () => {
expect(() => swallowMutationError(new ApiError(400, "bad url"))).not.toThrow();
expect(() => swallowMutationError(new TypeError("cannot read x of undefined"))).toThrow(TypeError);
expect(() => swallowMutationError("not an error at all")).toThrow();
});
test("a rejected submit leaves the typed values in place; a resolved one clears them", async () => {
const rejecting = vi.fn(() => Promise.reject(new ApiError(400, "bad url")));
const { rerender } = render(<BlocklistForm busy={false} error={null} onSubmit={rejecting} onCancel={undefined} />);
const url = screen.getByLabelText("URL") as HTMLInputElement;
const name = screen.getByLabelText("Name") as HTMLInputElement;
fireEvent.change(url, { target: { value: "https://example.com/list.txt" } });
fireEvent.change(name, { target: { value: "Example" } });
fireEvent.click(screen.getByRole("button", { name: "Add source" }));
await waitFor(() => expect(rejecting).toHaveBeenCalledTimes(1));
expect(url.value).toBe("https://example.com/list.txt");
expect(name.value).toBe("Example");
const resolving = vi.fn(() => Promise.resolve());
rerender(<BlocklistForm busy={false} error={null} onSubmit={resolving} onCancel={undefined} />);
fireEvent.click(screen.getByRole("button", { name: "Add source" }));
await waitFor(() => expect(url.value).toBe(""));
expect(name.value).toBe("");
});
+15 -3
View File
@@ -1,8 +1,19 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { ApiError } from "@/lib/api";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import type { Blocklist, BlocklistInput } from "@/lib/types"; import type { Blocklist, BlocklistInput } from "@/lib/types";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes"; import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
/**
* Drops the rejection the page already renders inline below the form. Anything
* else is a bug in this component and must reach the console instead of dying
* silently in the submit handler.
*/
export function swallowMutationError(error: unknown): void {
if (error instanceof ApiError) return;
throw error;
}
interface BlocklistFormProps { interface BlocklistFormProps {
initial?: Blocklist; initial?: Blocklist;
busy: boolean; busy: boolean;
@@ -20,14 +31,15 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
event.preventDefault(); event.preventDefault();
try { try {
await onSubmit({ url: url.trim(), name: name.trim(), enabled }); await onSubmit({ url: url.trim(), name: name.trim(), enabled });
} catch (error) {
swallowMutationError(error);
return;
}
if (initial === undefined) { if (initial === undefined) {
setUrl(""); setUrl("");
setName(""); setName("");
setEnabled(true); setEnabled(true);
} }
} catch {
// The page renders the mutation error inline below the form.
}
} }
return ( return (
@@ -1,9 +1,10 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store"; import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient"; import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes"; import { createAppRouter } from "@/routes";
import { clearRefreshStatus } from "@/features/blocklists/refreshStore";
const BLOCKLISTS = { const BLOCKLISTS = {
blocklists: [ blocklists: [
@@ -42,6 +43,7 @@ const RESPONSES: Record<string, unknown> = {
let resolveUpdate: ((response: Response) => void) | null; let resolveUpdate: ((response: Response) => void) | null;
beforeEach(() => { beforeEach(() => {
clearRefreshStatus();
resolveUpdate = null; resolveUpdate = null;
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
@@ -66,18 +68,35 @@ afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
function renderBlocklistsRoute() { function renderBlocklistsRoute(queryClient = createQueryClient()) {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/blocklists"] }), queryClient); const router = createAppRouter(createMemoryHistory({ initialEntries: ["/blocklists"] }), queryClient);
render( const view = render(
<AuthProvider> <AuthProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<RouterProvider router={router} /> <RouterProvider router={router} />
</QueryClientProvider> </QueryClientProvider>
</AuthProvider>, </AuthProvider>,
); );
return { queryClient, unmount: view.unmount };
} }
const SNAPSHOT = {
sources: [
{
id: 1,
state: "loaded",
loaded: true,
last_attempt: 1700000100,
last_success: 1700000100,
url: "https://example.com/hosts.txt",
last_error: "",
domains: 1200,
wildcards: 12,
skipped_regex: 4,
},
],
};
test("renders the source table and the status empty state", async () => { test("renders the source table and the status empty state", async () => {
renderBlocklistsRoute(); renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" }); await screen.findByRole("heading", { name: "Blocklists" });
@@ -149,7 +168,8 @@ test("update now disables the button, then replaces the status section from the
expect(screen.getByText("12")).toBeTruthy(); expect(screen.getByText("12")).toBeTruthy();
expect(screen.getByText("4")).toBeTruthy(); expect(screen.getByText("4")).toBeTruthy();
expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull(); expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
expect(screen.getByText(/Update completed/)).toBeTruthy(); // The store notifies one flush before the mutation's success state lands.
await screen.findByText(/Update completed/);
await waitFor(() => { await waitFor(() => {
const idle = screen.getByRole("button", { name: "Update now" }) as HTMLButtonElement; const idle = screen.getByRole("button", { name: "Update now" }) as HTMLButtonElement;
@@ -175,3 +195,35 @@ test("update now shows a countdown when rate limited with Retry-After", async ()
const alert = await screen.findByRole("alert"); const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Rate limited. Try again in 7s."); expect(alert.textContent).toBe("Rate limited. Try again in 7s.");
}); });
test("the refresh snapshot outlives the query cache's gcTime", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const { queryClient, unmount } = renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
await screen.findByRole("button", { name: "Updating…" });
resolveUpdate!(
new Response(JSON.stringify(SNAPSHOT), {
status: 202,
headers: { "content-type": "application/json" },
}),
);
await screen.findByText("loaded");
unmount();
// Well past the default 5-minute gcTime: an unsubscribed cache entry is
// collected by now, which is what used to erase the snapshot.
await act(async () => {
await vi.advanceTimersByTimeAsync(6 * 60_000);
});
renderBlocklistsRoute(queryClient);
await screen.findByRole("heading", { name: "Blocklists" });
expect(await screen.findByText("loaded")).toBeTruthy();
expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
} finally {
vi.useRealTimers();
}
});
@@ -8,10 +8,10 @@ import {
blocklistUpdateMutation, blocklistUpdateMutation,
blocklistsQuery, blocklistsQuery,
blocklistsUpdateNowMutation, blocklistsUpdateNowMutation,
queryKeys,
} from "@/lib/queries"; } from "@/lib/queries";
import type { Blocklist, BlocklistInput, SourceStatus } from "@/lib/types"; import type { Blocklist, BlocklistInput } from "@/lib/types";
import BlocklistForm from "./BlocklistForm"; import BlocklistForm from "./BlocklistForm";
import { useRefreshStatus } from "./refreshStore";
import SourceStatusSection from "./SourceStatusSection"; import SourceStatusSection from "./SourceStatusSection";
import { import {
dangerLinkButtonClass, dangerLinkButtonClass,
@@ -34,9 +34,7 @@ export default function BlocklistsPage() {
const remove = useMutation(blocklistDeleteMutation(queryClient)); const remove = useMutation(blocklistDeleteMutation(queryClient));
const updateNow = useMutation(blocklistsUpdateNowMutation(queryClient)); const updateNow = useMutation(blocklistsUpdateNowMutation(queryClient));
// Fed only by the update-now 202 snapshot (no GET exists); the mutation's const sources = useRefreshStatus();
// state change re-renders this page right after setQueryData runs.
const sources = queryClient.getQueryData<SourceStatus[]>(queryKeys.blocklistSources);
const namesById = new Map(blocklists.map((b) => [b.id, b.name])); const namesById = new Map(blocklists.map((b) => [b.id, b.name]));
async function submitForm(input: BlocklistInput) { async function submitForm(input: BlocklistInput) {
@@ -7,7 +7,7 @@ function formatAttempt(unixSeconds: number): string {
} }
interface SourceStatusSectionProps { interface SourceStatusSectionProps {
sources: SourceStatus[] | undefined; sources: SourceStatus[] | null;
namesById: ReadonlyMap<number, string>; namesById: ReadonlyMap<number, string>;
} }
@@ -15,7 +15,7 @@ export default function SourceStatusSection({ sources, namesById }: SourceStatus
return ( return (
<section className="mt-8"> <section className="mt-8">
<h2 className="text-lg font-medium">Source status</h2> <h2 className="text-lg font-medium">Source status</h2>
{sources === undefined ? ( {sources === null ? (
<p className="mt-2 text-zinc-500"> <p className="mt-2 text-zinc-500">
No status snapshot yet run Update now to fetch status for every enabled source. No status snapshot yet run Update now to fetch status for every enabled source.
</p> </p>
@@ -0,0 +1,31 @@
import { useSyncExternalStore } from "react";
import type { SourceStatus } from "@/lib/types";
// Client UI state, not server state: the snapshot exists only as the 202 body of
// POST /api/blocklists/update and no GET can refetch it. Held here so it outlives
// the query cache's gcTime instead of vanishing from an unsubscribed cache entry.
let snapshot: SourceStatus[] | null = null;
const listeners = new Set<() => void>();
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot(): SourceStatus[] | null {
return snapshot;
}
export function setRefreshStatus(sources: SourceStatus[]): void {
snapshot = sources;
for (const listener of listeners) listener();
}
export function clearRefreshStatus(): void {
snapshot = null;
for (const listener of listeners) listener();
}
export function useRefreshStatus(): SourceStatus[] | null {
return useSyncExternalStore(subscribe, getSnapshot);
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { useReducer, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { clientPrefixesPutMutation } from "@/lib/queries"; import { clientPrefixesPutMutation } from "@/lib/queries";
import type { ClientPrefix, Group } from "@/lib/types"; import type { ClientPrefix, Group } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor"; import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes"; import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes";
@@ -17,7 +18,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor); const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
const [validation, setValidation] = useState<string | null>(null); const [validation, setValidation] = useState<string | null>(null);
const dirty = isDirty(state); const dirty = isDirty(state);
const defaultGroupId = groups.find((group) => group.id === 1)?.id ?? groups[0]?.id ?? 1; const fallbackGroupId = defaultGroupId(groups);
const save = () => { const save = () => {
const problem = firstProblem(state.rows); const problem = firstProblem(state.rows);
@@ -96,7 +97,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
<div className="mt-4 flex gap-2"> <div className="mt-4 flex gap-2">
<button <button
type="button" type="button"
onClick={() => dispatch({ type: "add", groupId: defaultGroupId })} onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })}
className={buttonClass} className={buttonClass}
> >
Add prefix Add prefix
@@ -88,11 +88,21 @@ const RESPONSES: Record<string, unknown> = {
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }, "/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
}; };
// Endpoints forced to fail with a 4xx, which the query client does not retry.
let failing: Set<string>;
beforeEach(() => { beforeEach(() => {
failing = new Set();
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async (input: RequestInfo | URL) => { vi.fn(async (input: RequestInfo | URL) => {
const url = String(input); const url = String(input);
if (failing.has(url)) {
return new Response(JSON.stringify({ error: "upstream health unavailable" }), {
status: 400,
headers: { "content-type": "application/json" },
});
}
const payload = RESPONSES[url]; const payload = RESPONSES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }); if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), { return new Response(JSON.stringify(payload), {
@@ -161,3 +171,19 @@ test("period picker refetches stats and shows the empty chart state", async () =
await screen.findByText("No queries in this period."); await screen.findByText("No queries in this period.");
expect(screen.getByText("—", { selector: "span" })).toBeTruthy(); expect(screen.getByText("—", { selector: "span" })).toBeTruthy();
}); });
test("one failing endpoint degrades its own widget on cold navigation", async () => {
failing.add("/api/upstream/health");
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
// The page renders; only the upstream widget carries the error.
await screen.findByText("upstream health unavailable");
expect(screen.queryByText("Something went wrong")).toBeNull();
expect(screen.queryByText("Request failed (400)")).toBeNull();
expect(screen.getByText("1,000")).toBeTruthy();
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
expect(screen.getByText("Disk")).toBeTruthy();
expect(screen.queryByText("https://dns.example/dns-query")).toBeNull();
});
+1 -1
View File
@@ -11,8 +11,8 @@ import type { Blocklist, Group } from "@/lib/types";
import GroupSourcesEditor from "./GroupSourcesEditor"; import GroupSourcesEditor from "./GroupSourcesEditor";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { focusRing, primaryButtonClass, smallButtonClass, smallInputClass } from "@/ui/classes"; import { focusRing, primaryButtonClass, smallButtonClass, smallInputClass } from "@/ui/classes";
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
const DEFAULT_GROUP_ID = 1;
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted."; const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
const groupButtonClass = `${smallButtonClass} disabled:opacity-50`; const groupButtonClass = `${smallButtonClass} disabled:opacity-50`;
+3 -2
View File
@@ -3,6 +3,7 @@ import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
import { groupsQuery, lookupQuery } from "@/lib/queries"; import { groupsQuery, lookupQuery } from "@/lib/queries";
import type { Group, LookupResult } from "@/lib/types"; import type { Group, LookupResult } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { inputClass, largePrimaryButtonClass } from "@/ui/classes"; import { inputClass, largePrimaryButtonClass } from "@/ui/classes";
interface Submitted { interface Submitted {
@@ -128,10 +129,10 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
export default function LookupPage() { export default function LookupPage() {
const groups = useSuspenseQuery(groupsQuery()).data; const groups = useSuspenseQuery(groupsQuery()).data;
const defaultGroupId = groups.find((group) => group.id === 1)?.id ?? groups[0]?.id ?? 1; const preselectedGroupId = defaultGroupId(groups);
const [domain, setDomain] = useState(""); const [domain, setDomain] = useState("");
const [groupId, setGroupId] = useState(defaultGroupId); const [groupId, setGroupId] = useState(preselectedGroupId);
const [submitted, setSubmitted] = useState<Submitted | null>(null); const [submitted, setSubmitted] = useState<Submitted | null>(null);
const lookup = useQuery({ const lookup = useQuery({
+32 -7
View File
@@ -28,16 +28,17 @@ const RESPONSES: Record<string, unknown> = {
}, },
], ],
}, },
"/api/groups": {
groups: [
{ id: 1, name: "Default", safe_search: false },
{ id: 2, name: "Kids", safe_search: true },
],
},
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }, "/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
}; };
// The API orders groups by name, so the id-1 default is not always first.
let groups: { id: number; name: string; safe_search: boolean }[];
beforeEach(() => { beforeEach(() => {
groups = [
{ id: 1, name: "Default", safe_search: false },
{ id: 2, name: "Kids", safe_search: true },
];
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -48,7 +49,7 @@ beforeEach(() => {
headers: { "content-type": "application/json", "Retry-After": "5" }, headers: { "content-type": "application/json", "Retry-After": "5" },
}); });
} }
const payload = RESPONSES[url]; const payload = url === "/api/groups" ? { groups } : RESPONSES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }); if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), { return new Response(JSON.stringify(payload), {
status: 200, status: 200,
@@ -106,3 +107,27 @@ test("rule create shows a countdown when rate limited with Retry-After", async (
const alert = await screen.findByRole("alert"); const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Rate limited. Try again in 5s."); expect(alert.textContent).toBe("Rate limited. Try again in 5s.");
}); });
test("the group select preselects the id-1 default, not the alphabetically first group", async () => {
groups = [
{ id: 5, name: "Attic", safe_search: false },
{ id: 1, name: "Default", safe_search: false },
];
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
const groupSelect = screen.getByLabelText("Group") as HTMLSelectElement;
expect(Array.from(groupSelect.options).map((o) => o.textContent)).toEqual(["Attic", "Default"]);
expect(groupSelect.value).toBe("1");
});
test("the group select falls back to the first group when the default is absent", async () => {
groups = [
{ id: 5, name: "Attic", safe_search: false },
{ id: 7, name: "Basement", safe_search: false },
];
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
expect((screen.getByLabelText("Group") as HTMLSelectElement).value).toBe("5");
});
+2 -1
View File
@@ -4,6 +4,7 @@ import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries"; import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries";
import type { Rule, RuleAction, RuleKind } from "@/lib/types"; import type { Rule, RuleAction, RuleKind } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes"; import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
export default function RulesPage() { export default function RulesPage() {
@@ -17,7 +18,7 @@ export default function RulesPage() {
const [pattern, setPattern] = useState(""); const [pattern, setPattern] = useState("");
const [kind, setKind] = useState<RuleKind>("exact"); const [kind, setKind] = useState<RuleKind>("exact");
const [action, setAction] = useState<RuleAction>("block"); const [action, setAction] = useState<RuleAction>("block");
const [groupId, setGroupId] = useState(groups[0]?.id ?? 1); const [groupId, setGroupId] = useState(() => defaultGroupId(groups));
function onSubmit(event: FormEvent<HTMLFormElement>) { function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
@@ -1,11 +1,12 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { act } from "react"; import { act } from "react";
import SettingsPage, { patchRequiresRestart } from "@/features/settings/SettingsPage"; import SettingsPage, { patchRequiresRestart } from "@/features/settings/SettingsPage";
import RestartBanner from "@/features/settings/RestartBanner"; import RestartBanner from "@/features/settings/RestartBanner";
import { dismissRestartBanner } from "@/features/settings/restartBanner"; import { dismissRestartBanner } from "@/features/settings/restartBanner";
import { createQueryClient } from "@/lib/queryClient"; import { createQueryClient } from "@/lib/queryClient";
import { queryKeys } from "@/lib/queries";
import type { Settings, SettingsPatch } from "@/lib/types"; import type { Settings, SettingsPatch } from "@/lib/types";
function baseSettings(): Settings { function baseSettings(): Settings {
@@ -88,9 +89,10 @@ afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
async function renderPage() { async function renderPage(): Promise<QueryClient> {
const queryClient = createQueryClient();
render( render(
<QueryClientProvider client={createQueryClient()}> <QueryClientProvider client={queryClient}>
<RestartBanner /> <RestartBanner />
<Suspense fallback={<p>loading</p>}> <Suspense fallback={<p>loading</p>}>
<SettingsPage /> <SettingsPage />
@@ -98,6 +100,7 @@ async function renderPage() {
</QueryClientProvider>, </QueryClientProvider>,
); );
await screen.findByRole("heading", { name: "Settings" }); await screen.findByRole("heading", { name: "Settings" });
return queryClient;
} }
function saveButton(): HTMLButtonElement { function saveButton(): HTMLButtonElement {
@@ -232,3 +235,39 @@ test("patchRequiresRestart ignores only a bare web.password", () => {
expect(patchRequiresRestart({ dns: { port: 5353 } })).toBe(true); expect(patchRequiresRestart({ dns: { port: 5353 } })).toBe(true);
expect(patchRequiresRestart({ web: { password: "x" }, cache: { size: 1 } })).toBe(true); expect(patchRequiresRestart({ web: { password: "x" }, cache: { size: 1 } })).toBe(true);
}); });
test("a background refetch does not turn out-of-band changes into phantom patch entries", async () => {
const queryClient = await renderPage();
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5353" } });
// Someone else changes cache.size; a background refetch brings it in. The
// derived auth_enabled line is read straight from the query data, so it
// witnesses that the refetch reached the component.
storedSettings.cache.size = 99999;
storedSettings.web.auth_enabled = false;
await act(async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.settings });
});
await waitFor(() => expect(screen.getByText(/auth_enabled: false/)).toBeTruthy());
const cache = screen.getByRole("group", { name: "Cache" });
expect((within(cache).getByLabelText("size") as HTMLInputElement).value).toBe("10000");
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ dns: { port: 5353 } });
});
test("saving re-freezes the baseline, so the next diff starts from the server echo", async () => {
await renderPage();
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5353" } });
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
await waitFor(() => expect(saveButton().disabled).toBe(true));
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5454" } });
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(2));
expect(putBodies[1]).toEqual({ dns: { port: 5454 } });
});
+59 -36
View File
@@ -10,22 +10,40 @@ import { focusRing } from "@/ui/classes";
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */ /** True when the patch touches anything besides the write-only `web.password` (ruling 11). */
export function patchRequiresRestart(patch: SettingsPatch): boolean { export function patchRequiresRestart(patch: SettingsPatch): boolean {
return Object.entries(patch).some(([section, fields]) => return Object.entries(patch).some(([section, fields]) =>
Object.keys(fields as Record<string, unknown>).some((key) => !(section === "web" && key === "password")), Object.keys(fields ?? {}).some((key) => !(section === "web" && key === "password")),
); );
} }
interface FieldDef { interface FieldDef<S extends keyof Settings> {
key: string; key: keyof Settings[S] & string;
kind: "number" | "text" | "boolean" | readonly string[]; kind: "number" | "text" | "boolean" | readonly string[];
} }
interface SectionDef { interface SectionDef<S extends keyof Settings> {
section: keyof Settings; section: S;
title: string; title: string;
fields: readonly FieldDef[]; fields: readonly FieldDef<S>[];
} }
const TLS_FIELDS: readonly FieldDef[] = [ /** Binds each section's field keys to that section's Settings type at definition. */
function defineSection<S extends keyof Settings>(def: SectionDef<S>): SectionDef<S> {
return def;
}
/** The registry read back as a heterogeneous list, once the per-section binding has been proven. */
type AnyFieldDef = { [S in keyof Settings]: FieldDef<S> }[keyof Settings];
type AnySectionDef = { [S in keyof Settings]: SectionDef<S> }[keyof Settings];
/**
* A section's values as a string-keyed view. The keys are proven against
* `Settings[S]` where each section is defined; iterating the heterogeneous
* registry loses that correlation, so consumption widens here in one place.
*/
function sectionValues(settings: Settings, section: keyof Settings): Record<string, unknown> {
return settings[section] as Record<string, unknown>;
}
const TLS_FIELDS: readonly FieldDef<"doh_server" | "dot_server">[] = [
{ key: "enabled", kind: "boolean" }, { key: "enabled", kind: "boolean" },
{ key: "bind", kind: "text" }, { key: "bind", kind: "text" },
{ key: "port", kind: "number" }, { key: "port", kind: "number" },
@@ -33,8 +51,8 @@ const TLS_FIELDS: readonly FieldDef[] = [
{ key: "key_path", kind: "text" }, { key: "key_path", kind: "text" },
]; ];
const SECTIONS: readonly SectionDef[] = [ const SECTIONS: readonly AnySectionDef[] = [
{ defineSection({
section: "upstream", section: "upstream",
title: "Upstream", title: "Upstream",
fields: [ fields: [
@@ -42,8 +60,8 @@ const SECTIONS: readonly SectionDef[] = [
{ key: "read_timeout_ms", kind: "number" }, { key: "read_timeout_ms", kind: "number" },
{ key: "total_timeout_ms", kind: "number" }, { key: "total_timeout_ms", kind: "number" },
], ],
}, }),
{ defineSection({
section: "dns", section: "dns",
title: "DNS", title: "DNS",
fields: [ fields: [
@@ -53,24 +71,24 @@ const SECTIONS: readonly SectionDef[] = [
{ key: "rate_limit", kind: "number" }, { key: "rate_limit", kind: "number" },
{ key: "rate_window_seconds", kind: "number" }, { key: "rate_window_seconds", kind: "number" },
], ],
}, }),
{ defineSection({
section: "blocking", section: "blocking",
title: "Blocking", title: "Blocking",
fields: [ fields: [
{ key: "response", kind: ["zero", "nxdomain"] }, { key: "response", kind: ["zero", "nxdomain"] },
{ key: "ttl", kind: "number" }, { key: "ttl", kind: "number" },
], ],
}, }),
{ defineSection({
section: "cache", section: "cache",
title: "Cache", title: "Cache",
fields: [ fields: [
{ key: "size", kind: "number" }, { key: "size", kind: "number" },
{ key: "negative_ttl_max", kind: "number" }, { key: "negative_ttl_max", kind: "number" },
], ],
}, }),
{ defineSection({
section: "web", section: "web",
title: "Web", title: "Web",
fields: [ fields: [
@@ -83,11 +101,11 @@ const SECTIONS: readonly SectionDef[] = [
{ key: "sse_max_connections_per_ip", kind: "number" }, { key: "sse_max_connections_per_ip", kind: "number" },
{ key: "trusted_proxies", kind: "text" }, { key: "trusted_proxies", kind: "text" },
], ],
}, }),
{ section: "doh_server", title: "DoH Server", fields: TLS_FIELDS }, defineSection({ section: "doh_server", title: "DoH Server", fields: TLS_FIELDS }),
{ section: "dot_server", title: "DoT Server", fields: TLS_FIELDS }, defineSection({ section: "dot_server", title: "DoT Server", fields: TLS_FIELDS }),
{ section: "edns", title: "EDNS", fields: [{ key: "ecs_mode", kind: ["strip", "forward"] }] }, defineSection({ section: "edns", title: "EDNS", fields: [{ key: "ecs_mode", kind: ["strip", "forward"] }] }),
{ defineSection({
section: "logging", section: "logging",
title: "Logging", title: "Logging",
fields: [ fields: [
@@ -101,23 +119,23 @@ const SECTIONS: readonly SectionDef[] = [
{ key: "max_size_mb", kind: "number" }, { key: "max_size_mb", kind: "number" },
{ key: "max_files", kind: "number" }, { key: "max_files", kind: "number" },
], ],
}, }),
{ defineSection({
section: "disk", section: "disk",
title: "Disk", title: "Disk",
fields: [ fields: [
{ key: "min_free_mb", kind: "number" }, { key: "min_free_mb", kind: "number" },
{ key: "warn_free_mb", kind: "number" }, { key: "warn_free_mb", kind: "number" },
], ],
}, }),
{ defineSection({
section: "blocklist_update", section: "blocklist_update",
title: "Blocklist Update", title: "Blocklist Update",
fields: [ fields: [
{ key: "enabled", kind: "boolean" }, { key: "enabled", kind: "boolean" },
{ key: "interval_hours", kind: "number" }, { key: "interval_hours", kind: "number" },
], ],
}, }),
]; ];
const LABEL_CLASS = "text-sm text-zinc-700 dark:text-zinc-300"; const LABEL_CLASS = "text-sm text-zinc-700 dark:text-zinc-300";
@@ -130,7 +148,7 @@ function FieldRow({
onChange, onChange,
}: { }: {
section: string; section: string;
def: FieldDef; def: AnyFieldDef;
value: unknown; value: unknown;
onChange: (value: unknown) => void; onChange: (value: unknown) => void;
}) { }) {
@@ -209,23 +227,27 @@ export default function SettingsPage() {
const { data } = useSuspenseQuery(settingsQuery()); const { data } = useSuspenseQuery(settingsQuery());
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutation = useMutation(settingsPutMutation(queryClient)); const mutation = useMutation(settingsPutMutation(queryClient));
// Frozen at mount and re-frozen on save: diffing against live query data would
// turn a background refetch's out-of-band changes into phantom user edits.
const [baseline, setBaseline] = useState<Settings>(() => structuredClone(data.settings));
const [edited, setEdited] = useState<Settings>(() => structuredClone(data.settings)); const [edited, setEdited] = useState<Settings>(() => structuredClone(data.settings));
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState(""); const [confirm, setConfirm] = useState("");
const passwordsMismatch = (password !== "" || confirm !== "") && password !== confirm; const passwordsMismatch = (password !== "" || confirm !== "") && password !== confirm;
const hasInvalidNumber = SECTIONS.some(({ section, fields }) => const hasInvalidNumber = SECTIONS.some(({ section, fields }) => {
fields.some( const values = sectionValues(edited, section);
(field) => field.kind === "number" && Number.isNaN((edited[section] as Record<string, unknown>)[field.key]), return (fields as readonly AnyFieldDef[]).some(
), (field) => field.kind === "number" && Number.isNaN(values[field.key]),
); );
const patch = buildSettingsPatch(data.settings, edited, password === "" ? undefined : password); });
const patch = buildSettingsPatch(baseline, edited, password === "" ? undefined : password);
const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending; const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending;
function setField(section: keyof Settings, key: string, value: unknown): void { function setField(section: keyof Settings, key: string, value: unknown): void {
setEdited((prev) => ({ setEdited((prev) => ({
...prev, ...prev,
[section]: { ...(prev[section] as Record<string, unknown>), [key]: value }, [section]: { ...sectionValues(prev, section), [key]: value },
})); }));
} }
@@ -235,6 +257,7 @@ export default function SettingsPage() {
const restartNeeded = patchRequiresRestart(patch); const restartNeeded = patchRequiresRestart(patch);
mutation.mutate(patch, { mutation.mutate(patch, {
onSuccess: (envelope) => { onSuccess: (envelope) => {
setBaseline(structuredClone(envelope.settings));
setEdited(structuredClone(envelope.settings)); setEdited(structuredClone(envelope.settings));
setPassword(""); setPassword("");
setConfirm(""); setConfirm("");
@@ -255,12 +278,12 @@ export default function SettingsPage() {
<fieldset key={section} className="rounded border border-zinc-200 p-4 dark:border-zinc-800"> <fieldset key={section} className="rounded border border-zinc-200 p-4 dark:border-zinc-800">
<legend className="px-1 text-sm font-semibold">{title}</legend> <legend className="px-1 text-sm font-semibold">{title}</legend>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{fields.map((def) => ( {(fields as readonly AnyFieldDef[]).map((def) => (
<FieldRow <FieldRow
key={def.key} key={def.key}
section={section} section={section}
def={def} def={def}
value={(edited[section] as Record<string, unknown>)[def.key]} value={sectionValues(edited, section)[def.key]}
onChange={(value) => setField(section, def.key, value)} onChange={(value) => setField(section, def.key, value)}
/> />
))} ))}
+13
View File
@@ -0,0 +1,13 @@
import type { Group } from "@/lib/types";
/** The seeded group every client falls back to; the API forbids renaming or deleting it. */
export const DEFAULT_GROUP_ID = 1;
/**
* The group a form preselects. The list arrives ordered by name
* (groups_repo.zig), so `groups[0]` is the alphabetically first group, not the
* default it is only the fallback for a list that lost the seeded group.
*/
export function defaultGroupId(groups: readonly Group[]): number {
return groups.find((group) => group.id === DEFAULT_GROUP_ID)?.id ?? groups[0]?.id ?? DEFAULT_GROUP_ID;
}
+13 -12
View File
@@ -1,5 +1,6 @@
import { infiniteQueryOptions, keepPreviousData, queryOptions, type QueryClient } from "@tanstack/react-query"; import { infiniteQueryOptions, keepPreviousData, queryOptions, type QueryClient } from "@tanstack/react-query";
import * as api from "@/lib/api"; import * as api from "@/lib/api";
import { setRefreshStatus } from "@/features/blocklists/refreshStore";
import type { import type {
BlocklistInput, BlocklistInput,
ClientEdit, ClientEdit,
@@ -24,11 +25,11 @@ export const queryKeys = {
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const, queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const,
upstreamHealth: ["upstream-health"] as const, upstreamHealth: ["upstream-health"] as const,
lookup: (domain: string, groupId?: number) => ["lookup", domain, groupId ?? null] as const, lookup: (domain: string, groupId?: number) => ["lookup", domain, groupId ?? null] as const,
/** Prefix of every `lookup` entry; the invalidation target after any verdict input changes. */
lookupAll: ["lookup"] as const,
groups: ["groups"] as const, groups: ["groups"] as const,
groupSources: (id: number) => ["groups", id, "sources"] as const, groupSources: (id: number) => ["groups", id, "sources"] as const,
blocklists: ["blocklists"] as const, blocklists: ["blocklists"] as const,
/** Fed only by POST /api/blocklists/update's 202 snapshot; no GET exists. */
blocklistSources: ["blocklists", "sources"] as const,
rules: ["rules"] as const, rules: ["rules"] as const,
localRecords: ["local-records"] as const, localRecords: ["local-records"] as const,
forwardZones: ["forward-zones"] as const, forwardZones: ["forward-zones"] as const,
@@ -107,7 +108,7 @@ export const settingsQuery = () => queryOptions({ queryKey: queryKeys.settings,
function invalidateGroupWorld(qc: QueryClient): Promise<unknown> { function invalidateGroupWorld(qc: QueryClient): Promise<unknown> {
return Promise.all([ return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.groups }), qc.invalidateQueries({ queryKey: queryKeys.groups }),
qc.invalidateQueries({ queryKey: ["lookup"] }), qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
qc.invalidateQueries({ queryKey: queryKeys.clients }), qc.invalidateQueries({ queryKey: queryKeys.clients }),
qc.invalidateQueries({ queryKey: queryKeys.clientPrefixes }), qc.invalidateQueries({ queryKey: queryKeys.clientPrefixes }),
qc.invalidateQueries({ queryKey: queryKeys.rules }), qc.invalidateQueries({ queryKey: queryKeys.rules }),
@@ -133,15 +134,15 @@ export const groupSourcesPutMutation = (qc: QueryClient) => ({
mutationFn: ({ id, sourceIds }: { id: number; sourceIds: number[] }) => api.putGroupSources(id, sourceIds), mutationFn: ({ id, sourceIds }: { id: number; sourceIds: number[] }) => api.putGroupSources(id, sourceIds),
onSuccess: (sourceIds: number[], { id }: { id: number; sourceIds: number[] }) => { onSuccess: (sourceIds: number[], { id }: { id: number; sourceIds: number[] }) => {
qc.setQueryData(queryKeys.groupSources(id), sourceIds); qc.setQueryData(queryKeys.groupSources(id), sourceIds);
return qc.invalidateQueries({ queryKey: ["lookup"] }); return qc.invalidateQueries({ queryKey: queryKeys.lookupAll });
}, },
}); });
function invalidateBlocklistWorld(qc: QueryClient): Promise<unknown> { function invalidateBlocklistWorld(qc: QueryClient): Promise<unknown> {
return Promise.all([ return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.blocklists }), qc.invalidateQueries({ queryKey: queryKeys.blocklists }),
qc.invalidateQueries({ queryKey: ["lookup"] }), qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
qc.invalidateQueries({ queryKey: ["groups"] }), qc.invalidateQueries({ queryKey: queryKeys.groups }),
]); ]);
} }
@@ -160,14 +161,14 @@ export const blocklistDeleteMutation = (qc: QueryClient) => ({
onSuccess: () => invalidateBlocklistWorld(qc), onSuccess: () => invalidateBlocklistWorld(qc),
}); });
/** Ruling 12: the 202 snapshot REPLACES the sources cache; counters refresh. */ /** Ruling 12: the 202 snapshot REPLACES the refresh store; counters refresh. */
export const blocklistsUpdateNowMutation = (qc: QueryClient) => ({ export const blocklistsUpdateNowMutation = (qc: QueryClient) => ({
mutationFn: () => api.updateBlocklistsNow(), mutationFn: () => api.updateBlocklistsNow(),
onSuccess: (sources: Awaited<ReturnType<typeof api.updateBlocklistsNow>>) => { onSuccess: (sources: Awaited<ReturnType<typeof api.updateBlocklistsNow>>) => {
qc.setQueryData(queryKeys.blocklistSources, sources); setRefreshStatus(sources);
return Promise.all([ return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.blocklists }), qc.invalidateQueries({ queryKey: queryKeys.blocklists }),
qc.invalidateQueries({ queryKey: ["lookup"] }), qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
]); ]);
}, },
}); });
@@ -175,7 +176,7 @@ export const blocklistsUpdateNowMutation = (qc: QueryClient) => ({
function invalidateRules(qc: QueryClient): Promise<unknown> { function invalidateRules(qc: QueryClient): Promise<unknown> {
return Promise.all([ return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.rules }), qc.invalidateQueries({ queryKey: queryKeys.rules }),
qc.invalidateQueries({ queryKey: ["lookup"] }), qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
]); ]);
} }
@@ -192,7 +193,7 @@ export const ruleDeleteMutation = (qc: QueryClient) => ({
function invalidateLocalRecords(qc: QueryClient): Promise<unknown> { function invalidateLocalRecords(qc: QueryClient): Promise<unknown> {
return Promise.all([ return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.localRecords }), qc.invalidateQueries({ queryKey: queryKeys.localRecords }),
qc.invalidateQueries({ queryKey: ["lookup"] }), qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
]); ]);
} }
@@ -214,7 +215,7 @@ export const localRecordDeleteMutation = (qc: QueryClient) => ({
function invalidateForwardZones(qc: QueryClient): Promise<unknown> { function invalidateForwardZones(qc: QueryClient): Promise<unknown> {
return Promise.all([ return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.forwardZones }), qc.invalidateQueries({ queryKey: queryKeys.forwardZones }),
qc.invalidateQueries({ queryKey: ["lookup"] }), qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
]); ]);
} }
+4 -1
View File
@@ -92,8 +92,11 @@ const shellRoute = createRoute({
const dashboardRoute = createRoute({ const dashboardRoute = createRoute({
getParentRoute: () => shellRoute, getParentRoute: () => shellRoute,
path: "/", path: "/",
// allSettled, not all: DashboardPage reads these with useQuery so each widget
// can render its own error. A rejecting loader would replace the whole page
// with RouteError and take the three healthy widgets down with the failed one.
loader: ({ context }) => loader: ({ context }) =>
Promise.all([ Promise.allSettled([
context.queryClient.ensureQueryData(statsQuery("24h")), context.queryClient.ensureQueryData(statsQuery("24h")),
context.queryClient.ensureQueryData(timeseriesQuery("24h")), context.queryClient.ensureQueryData(timeseriesQuery("24h")),
context.queryClient.ensureQueryData(healthQuery()), context.queryClient.ensureQueryData(healthQuery()),