857 lines
58 KiB
Markdown
857 lines
58 KiB
Markdown
# Milestone 8: web server, REST API, SSE, auth, metrics (PLAN Phase 8, Zig side)
|
||
|
||
Goal: the complete Zig web layer — HTTP server + router, every REST handler, SSE live query
|
||
stream, optional argon2id auth with sessions, API token-bucket rate limiting, `/metrics`,
|
||
`/api/health`, OpenAPI served + contract tests, asset embedding + dev-mode disk serving —
|
||
running as one more task in app.zig's group.
|
||
|
||
Ground truth: PLAN.md:610-612 (phase text), :537-550 (endpoints), §3.11/§12.1/§19 (auth),
|
||
§10:333 (API limiting), §11.4:455 (SSE precedes persistence), §13.2 (OpenAPI), scratchpad
|
||
notes m8-explore-{plan,repo,zig}.md. Load-bearing Zig facts are restated inline; verify
|
||
anything else against /home/mokhtar/app/zig tag 0.16.0.
|
||
|
||
## Rulings (binding)
|
||
|
||
1. **Sequencing**: this milestone is the Zig web layer end to end. The React SPA (PLAN §3.14,
|
||
§14 — ten pages) is milestone 9, built against this milestone's finished, contract-tested
|
||
API. Not a scope cut: the asset embedding, dev-mode disk serving, ETag and gzip machinery
|
||
all ship NOW and are complete; milestone 9 only swaps the dist content. The embedded dist
|
||
in this milestone is a minimal real page (see ruling 24) — machinery is not stubbed.
|
||
2. **`POST /api/certs/reload` is Phase 9's**, whole: endpoint, openapi.yaml entry and docs
|
||
land together with the DoH/DoT server it reloads. No 501 stub, no dangling contract entry.
|
||
3. **`docs/api/` rendering is Phase 10** (the docs phase). Phase 8 serves the yaml.
|
||
4. **Server model**: per-connection `std.http.Server` (Server.zig:25) over our own accept
|
||
loop, copied from lib/std/Build/WebServer.zig:152-185: listener task in the app group; an
|
||
inner `Io.Group` of connection tasks; keep-alive loop on `receiveHead` with
|
||
`error.HttpConnectionClosing => return`. Cancel semantics mirror tcp_server's S3 As-built:
|
||
on cancellation the inner group is CANCELED, not awaited (a keep-alive client must not
|
||
hold shutdown open); on listener close (`SocketNotListening`) it drains.
|
||
5. **Bind**: one listener socket on `web.bind:web.port` exactly as configured (default
|
||
0.0.0.0:8080). No dual-stack ceremony — milestone 7's parity ruling was about DNS client
|
||
identity, not the admin UI. An operator who wants v6 sets `web.bind = "::"` (dual-stack by
|
||
Linux default).
|
||
6. **`web.enabled = false`** skips the entire subsystem, `/metrics` included. No web task, no
|
||
web DB connections.
|
||
7. **Limits**: recv buffer 8 KiB (this caps the request head — Server.zig:32); send buffer
|
||
4 KiB; request body cap 1 MiB (`Io.Limit`, read via `readerExpectNone`/`allocRemaining`);
|
||
64 concurrent connections (accept beyond that: respond 503 and close — never silently
|
||
drop). No per-request timeout this milestone (LAN-facing; the cancel path bounds
|
||
shutdown); documented in server.zig.
|
||
8. **JSON shape**: snake_case field names everywhere (matches settings keys and SQL). Error
|
||
envelope `{"error":"<message>"}`. Status codes: 400 validation, 401 unauthenticated,
|
||
404 missing, 405 wrong method (with Allow), 409 conflict (duplicate key), 413 body too
|
||
large, 429 rate limited (with Retry-After), 500 internal (generic message, detail to log
|
||
as warn), 503 over connection cap.
|
||
9. **Resource paths** (freezing PLAN:541's ellipses): collections `GET`+`POST` and items
|
||
`GET`+`PUT`+`DELETE` by numeric row id for: `/api/groups[/{id}]`,
|
||
`/api/blocklists[/{id}]` (the sources table), `/api/rules[/{id}]`,
|
||
`/api/local-records[/{id}]`, `/api/forward-zones[/{id}]`, `/api/upstreams[/{id}]`
|
||
(PLAN's list omitted upstreams; the Settings page must edit them; new resource, same
|
||
pattern). Group-source assignment: `PUT /api/groups/{id}/sources` with `{"source_ids":
|
||
[..]}` — idempotent full-set replace. Clients: `GET /api/clients` (ALL rows, materialized
|
||
included, each with `hand_edited`), `GET/PUT/DELETE /api/clients/{id}`; PUT sets
|
||
`hand_edited=1` and may change name/group; DELETE removes the row (a live client
|
||
re-materializes). Client prefixes: `GET/PUT /api/client-prefixes` as a whole-list
|
||
resource (tiny table, atomic replace). No POST for clients — creation is by DNS activity
|
||
or import (PLAN:540 gives clients no POST deliberately).
|
||
10. **Repo layer**: every list row the API serves carries its row id; each mutated resource
|
||
gains `getX(db, id)`, `updateX(db, id, item)`, `deleteX(db, id)` (strict: 0 rows touched
|
||
→ error.NotFound), written in the house repo idiom with prepared statements. Existing
|
||
import-path functions stay frozen.
|
||
11. **`GET /api/queries`**: keyset pagination `?limit` (default 100, max 1000) +
|
||
`?before=<row id>`, ordered id DESC; filters `domain=` (substring), `client=` (exact),
|
||
`blocked=` (bool), `since=`/`until=` (unix seconds). Response `{"queries":[...],
|
||
"next_before": <id>|null}`. Row fields: id, ts, domain, client_ip, qtype, blocked,
|
||
block_reason, response_time_us, cache_hit, upstream.
|
||
12. **Live vs restart**: mutations to rules, blocklists, groups, group-sources, clients and
|
||
client-prefixes call `Manager.reload(io)` and take effect live (the handler's next
|
||
`acquire` sees the new snapshot). Local records and forward zones ALSO apply live: a new
|
||
`src/server/local_tables.zig` RCU holder (acquire/release + swap, mirroring the manager's
|
||
pattern at small scale) owns `{records, zones}`; `Handler` reads through it instead of
|
||
`*const` fields; the local-records/forward-zones handlers rebuild and swap. Upstream
|
||
mutations and everything in `/api/settings` are restart-required. `POST
|
||
/api/blocklists/update` = `Manager.refreshAll` then reload, 202 with the status snapshot.
|
||
13. **Stats grammar**: `period=1h|24h|7d|30d` (else 400). `/api/stats` returns totals
|
||
{queries, blocked, cached, clients (distinct), avg_response_time_us} for the period.
|
||
`/api/stats/timeseries` buckets: 1h→60×1m, 24h→48×30m, 7d→168×1h, 30d→120×6h; UTC; each
|
||
bucket {ts, queries, blocked, cached}. SQL aggregates over query_log on the web task's
|
||
own read connection.
|
||
14. **`GET /api/lookup?domain=&group_id=`** (group optional, default group when absent):
|
||
normalizes, then reports the full pipeline view: `{domain, group_id, local_records:
|
||
bool, forward_zone: <zone>|null, blocked, reason, matched, source_url|null,
|
||
safe_search_rewrite: <target>|null}` via `snapshot.evaluate` + records/zones lookups.
|
||
Null snapshot → 503 `{"error":"no snapshot loaded"}`.
|
||
15. **Pause API**: `GET /api/pause` → `{"paused": bool, "until": <unix s>|null}` (null while
|
||
unpaused OR indefinite — disambiguated by `paused`). `POST /api/pause` body
|
||
`{"paused": true, "duration_seconds": <u32, optional>}` or `{"paused": false}`.
|
||
16. **`GET/PUT /api/settings`**: the typed config sections that live in settings rows (dns,
|
||
blocking, cache, edns, upstream, logging, disk, blocklist_update, safe_search flag home
|
||
— read model.toSettings for the exact key list), minus `web.password*` (never
|
||
serialized; a PUT carrying `web.password` re-hashes via the import path's argon2id
|
||
params and stores only the hash). GET marks every key `restart_required: true` except
|
||
none — ALL settings are restart-required this milestone (live behavior comes from the
|
||
resource endpoints and pause; a static table in the handler carries the flag so the UI
|
||
banner has transport). PUT validates the merged config through `validate.validate`
|
||
before writing any row; partial updates allowed.
|
||
17. **Auth**: enabled iff `web.password_hash != ""`. Login verifies with
|
||
`std.crypto.pwhash.argon2.strVerify(hash, password, .{ .allocator = gpa }, io)`
|
||
(argon2.zig:619 — PHC string carries its params). Sessions: in-memory fixed table of 32
|
||
(LRU evict), storing SHA-256 of a 32-byte `io.randomSecure` token; lookup compares
|
||
digests with `std.crypto.timing_safe.eql([32]u8, ...)` (slices not accepted —
|
||
timing_safe.zig:12). Cookie `nxdns_session=<url_safe_no_pad base64>`; `HttpOnly;
|
||
SameSite=Lax; Path=/`; `Secure` NOT set (nxdns serves plain HTTP; TLS termination is the
|
||
operator's proxy — documented). Expiry `web.session_ttl_hours`. Logout deletes the
|
||
session. A `PUT /api/settings` that changes `password_hash` clears every session.
|
||
18. **Auth exemptions**: `/api/health`, `/api/version`, `/metrics`, `/api/openapi.yaml`, and
|
||
the static assets are always unauthenticated (monitoring endpoints; the SPA shell must
|
||
load to show a login form). Everything else 401s without a valid session when auth is
|
||
enabled. `/api/auth/login` is necessarily exempt; failed logins count and are rate
|
||
limited like any request.
|
||
19. **API rate limiting**: token bucket per client IP (NOT the DNS fixed-window limiter —
|
||
m6 ruling 8 reserved the bucket shape for the API): capacity and refill
|
||
`web.api_rate_limit_per_min` per 60 s, 4096 tracked IPs (same fixed-table idiom as
|
||
rate_limiter/tracker). Localhost exemption: NEW config field `web.api_localhost_exempt:
|
||
bool = true` (PLAN §10 says "configurable" and §12.1 forgot the field; model + validate
|
||
+ settings key + export round trip — settings are kv rows, no migration needed). 429 +
|
||
`Retry-After: <s>`. SSE: connect consumes a token AND respects
|
||
`web.sse_max_connections_per_ip`; `/metrics` and `/api/health` are limiter-exempt
|
||
(Prometheus must never see 429).
|
||
20. **SSE**: `GET /api/queries/live`, `text/event-stream` via `respondStreaming(&.{}, ...)`
|
||
(EMPTY buffer — `BodyWriter.flush` does not flush the body writer's own buffer,
|
||
http.zig:780; empty buffer makes every write go straight to the chunked drain), flush
|
||
headers before the first event (test.zig:498 pattern). Fanout: milestone-6 ruling 4
|
||
concretized as `src/server/query_sink.zig`: `QuerySink { logger: *Logger, hub: ?*sse.Hub
|
||
}` with `log(io, entry)` = transform once, publish to the hub, enqueue to the logger.
|
||
Logger gains additive `transformed(entry) Entry` (pure) and `logTransformed(io, entry)`;
|
||
existing `log` = both, frozen behavior. `Handler.logger: ?*Logger` becomes `sink:
|
||
?*QuerySink` (mechanical rename at the two call sites + app wiring). Hub: fixed 32
|
||
subscriber slots, each a 64-entry ring + `std.Io.Event`; publish never blocks (full ring
|
||
→ disconnect that subscriber; SSE clients auto-reconnect). Wire format: `retry: 3000`
|
||
once, then `event: query` + `data: <JSON, same fields as /api/queries rows>` per entry;
|
||
heartbeat comment `: ping` every 15 s from the subscriber task (Event.waitTimeout).
|
||
Fanout precedes persistence (PLAN:455) — the sink publishes before enqueue.
|
||
21. **`/metrics`**: Prometheus text format 0.0.4, `nxdns_` prefix, `# HELP`/`# TYPE` lines.
|
||
Counters: every `Handler.Stats` field (17, individually, `nxdns_dns_<field>_total`),
|
||
logger (queries_dropped, rows_written, batches_gated), cache stats (6, under
|
||
`Handler.cache_mutex`), DNS limiter stats (under `limiter_mutex`), tracker stats,
|
||
retention stats, log-sink stats, `nxdns_blocklist_refreshes_gated_total`. Gauges:
|
||
cache len + memory bytes, disk free/db/log bytes, tracked DNS clients, pending tracker
|
||
clients, blocklist generation, `nxdns_up 1`. Per-upstream, labeled `{url="..."}`:
|
||
up (available), success_rate, consecutive_failures, total_successes/_failures (copy
|
||
`Pool.Snapshot` fields while holding the returned count — `last_error` is borrowed,
|
||
copy immediately). No timestamps. Auth-exempt, limiter-exempt.
|
||
22. **`GET /api/health`**: `{"status":"ok"|"degraded","disk":{"state":...,
|
||
"free_bytes":...,"db_bytes":...,"log_bytes":...,"sample_failures":...},
|
||
"upstreams":{"available":N,"total":M},"queries_dropped":N,"writer_failed":bool,
|
||
"refreshes_gated":N,"snapshot_generation":N|null}`. Degraded iff disk state != ok, or
|
||
zero upstreams available, or writer_failed. 200 either way (degraded is data, not an
|
||
HTTP failure).
|
||
23. **OpenAPI + contract tests**: hand-written `src/web/openapi.yaml`, `@embedFile`d and
|
||
served verbatim. No external validator (dependencies are liabilities): the contract IS a
|
||
Zig table in the integration test — every route with method, auth requirement, a seeded
|
||
request, expected status, and a response-shape struct that `std.json.parseFromSlice`
|
||
must accept with `.ignore_unknown_fields = false`. Two drift guards: (a) the router
|
||
exposes `pub const routes: []const RouteInfo`; a test asserts every entry's path+method
|
||
appears textually in openapi.yaml; (b) the table covers every `routes` entry (count
|
||
equality). CI gets one new job step running the existing `-Dintegration` suite (contract
|
||
tests live inside it) — no new tooling.
|
||
24. **Static assets**: build.zig gains `-Dweb-dist=<path>` (default `web/dist-placeholder/`,
|
||
committed, containing a real minimal `index.html` — current status via /api/health
|
||
fetch, links to /metrics; plus favicon). A build step copies the dist dir via
|
||
`b.addWriteFiles()` + generated `assets.zig` index (the fixtures anonymous-import
|
||
pattern, per research notes — @embedFile paths must live inside the module root),
|
||
exposing `pub const files: []const File{path, bytes, content_type, etag}`; etag =
|
||
comptime hash. Serving: exact path match, `/` → index.html, SPA fallback (unknown
|
||
non-/api path → index.html, 200 — TanStack Router needs it in M9), `ETag`/
|
||
`If-None-Match` → 304, `content-encoding: gzip` when a sibling `<name>.gz` exists in
|
||
the dist AND the request accepts it. No Date/Last-Modified headers (std has no RFC 1123
|
||
formatter; ETag is strictly better for immutable embedded content). Dev mode: CLI flag
|
||
`nxdns run --web-dev <dir>` serves from disk (no cache headers), bypassing the embed.
|
||
25. **Head-string trap**: `request.head.target` is invalidated by body reads
|
||
(Server.zig:594/230). The router copies target into a stack buffer before any body
|
||
read. Query strings: split on '?', `std.mem.splitScalar` for pairs,
|
||
`Uri.percentDecodeInPlace` per value (std has no query iterator; std.Uri does not parse
|
||
origin-form targets).
|
||
26. **Wiring**: one `web_server.serve` task in the app group, started last, canceled by the
|
||
same group.cancel. `WebState` struct (in web/server.zig) of borrowed pointers assembled
|
||
by app.serve: handler (stats + mutexes + cache/limiter), pause, tracker, manager, pool,
|
||
monitor, logger, retention stats, local_tables, sink/hub, config arena view (web cfg),
|
||
its OWN `config.db` + `querylog.db` connections (m7 ruling 21), version string, started
|
||
timestamp. Web DB connections open only when `web.enabled`.
|
||
27. **No new schema migration**: query_log already carries what /api/queries needs; the
|
||
read session verifies existing indexes and reports if a needed index is missing (then
|
||
the orchestrator rules on adding migration v3 — do not add one silently).
|
||
28. **Client disconnect**: stream writes surface `error.WriteFailed` with the real cause in
|
||
`stream_writer.err` (ConnectionResetByPeer / SocketUnconnected=EPIPE — MSG.NOSIGNAL is
|
||
set, Threaded.zig:13071, no SIGPIPE masking needed). A failed write ends the connection
|
||
task quietly (debug log at most): clients vanishing is normal.
|
||
29. **Input sanitation** (PLAN §19): every path/query/body input is length-capped and
|
||
validated before SQL (prepared statements everywhere, already house rule); domain
|
||
inputs run through matcher.normalize; no secrets ever logged (password, tokens, cookie
|
||
values — assert by review, and the login handler logs only the client IP and outcome).
|
||
|
||
## Sessions
|
||
|
||
Wave 1 (parallel): W1 query-log reads, W2 repo CRUD, W3 web core, W4 auth+limiter,
|
||
W5 sink+SSE hub+logger split.
|
||
Wave 2 (after W3, parallel where files allow): W6 read handlers + metrics, W7 mutation
|
||
handlers + local_tables + handler.zig sink/local_tables changes.
|
||
Wave 3: W8 static assets + build.zig + openapi.yaml + router registration of everything.
|
||
Wave 4: W9 app/cli wiring. Wave 5: W10 integration + contract tests + CI.
|
||
The orchestrator wires src/tests.zig per wave and rules on anything a session flags.
|
||
|
||
---
|
||
|
||
## Session W1: query-log read layer
|
||
|
||
Owns: `src/storage/repositories/queries_repo.zig` (additive; BatchWriter and friends frozen).
|
||
|
||
```zig
|
||
pub const QueryRow = struct { id: i64, ts: i64, domain: []const u8, client_ip: []const u8,
|
||
qtype: ?u16, blocked: bool, block_reason: []const u8, response_time_us: ?i64,
|
||
cache_hit: ?bool, upstream: []const u8 };
|
||
pub const QueryFilter = struct { limit: u32 = 100, before: ?i64 = null,
|
||
domain_substring: ?[]const u8 = null, client: ?[]const u8 = null,
|
||
blocked: ?bool = null, since: ?i64 = null, until: ?i64 = null };
|
||
/// Rows come back newest-first. Strings are arena-allocated.
|
||
pub fn selectQueries(database: *db.Db, arena: Allocator, filter: QueryFilter)
|
||
db.Error!std.ArrayList(QueryRow)
|
||
pub const StatsTotals = struct { queries: u64, blocked: u64, cached: u64,
|
||
distinct_clients: u64, avg_response_time_us: ?i64 };
|
||
pub fn statsTotals(database: *db.Db, since: i64, until: i64) db.Error!StatsTotals
|
||
pub const Bucket = struct { ts: i64, queries: u64, blocked: u64, cached: u64 };
|
||
/// Fills `out` with fixed-width buckets covering [since, until); returns the count.
|
||
pub fn timeseries(database: *db.Db, since: i64, bucket_seconds: u32, out: []Bucket)
|
||
db.Error!usize
|
||
```
|
||
|
||
Read the real query_log schema first (querylog_schema.zig) — domain interning means a JOIN;
|
||
verify what indexes exist and REPORT (ruling 27) if `ts` or the join needs one that is
|
||
missing rather than adding a migration. Dynamic WHERE assembly must still use bound
|
||
parameters only (build the SQL from fixed fragments, never interpolate values). Cap
|
||
`limit` at 1000 in the repo too. Tests: in-memory db seeded via BatchWriter; filters,
|
||
keyset paging across a boundary, bucket alignment, empty ranges, substring escaping
|
||
(`%`/`_` in domain must not act as wildcards — use ESCAPE).
|
||
|
||
Acceptance: fmt/ast clean; temp-root (-lc -lsqlite3) green; frozen surface untouched.
|
||
|
||
### W1 As built
|
||
|
||
`pub const max_limit: u32 = 1000` exported. WHERE assembly: six comptime fragments copied
|
||
into a comptime-sized stack buffer (overflow impossible by construction), one bare `?` per
|
||
predicate, bind order = append order; `likePattern` escapes `%`, `_` and `\` with
|
||
`ESCAPE '\'`. Ruling 27 discharged: EXPLAIN QUERY PLAN over all six query shapes rides
|
||
idx_query_log_client / idx_query_log_ts / rowid — NO migration v3; the time-windowed page's
|
||
temp B-tree re-sort and the DISTINCT count's temp B-tree are inherent and cheap at household
|
||
volume. `statsTotals` computes the mean as sum/count integer division (db.Stmt has no float
|
||
column reader; avg() returns REAL) — exact microseconds, null when no row recorded a time.
|
||
NULL `block_reason`/`upstream` arrive as `""` (columnText convention; neither is ever
|
||
written as an empty string). **W6 convention, ruled now: `""` serializes as `""`, not JSON
|
||
null** — `upstream=""` already means "cache hit" per milestone-7 ruling 20 and clients
|
||
branch on `blocked`/`cache_hit`, not on reason presence. `timeseries` returns
|
||
`error.Misuse` for bucket_seconds == 0 or an i64 window overflow, returns 0 for an empty
|
||
`out` without touching the database, and aligns buckets to `since` — the HANDLER passes a
|
||
grid-aligned since per ruling 13. 14 new tests (43 in file).
|
||
|
||
---
|
||
|
||
## Session W2: repo CRUD by id
|
||
|
||
Owns every file in `src/storage/repositories/` EXCEPT queries_repo.zig (W1's), plus nothing
|
||
else. Additive only; import-path functions frozen.
|
||
|
||
For groups, clients (+prefixes), upstreams, sources, rules, local records, forward zones:
|
||
id-carrying list variants (or extend existing rows where the shape already has no
|
||
consumers outside export — check callers first; export/import must keep compiling
|
||
unchanged), `getX(db, id) db.Error!?XRow`, `insertXRow(db, item) db.Error!i64` (returns
|
||
id; distinct from frozen import inserts where semantics differ — e.g. clients insert with
|
||
hand_edited=1), `updateX(db, id, item) db.Error!void` (0 rows → error.NotFound),
|
||
`deleteX(db, id) db.Error!void` (same), `setGroupSources(db, group_id, source_ids)
|
||
db.Error!void` (transactional replace), `replaceClientPrefixes(db, items) db.Error!void`.
|
||
Respect FK constraints: deleting a group with clients → error.Constraint surfaces as 409 at
|
||
the handler; document each. `settings_repo`: `putSetting(db, key, value)` single-key upsert
|
||
if absent. Tests per resource: round trip, NotFound on both update and delete, constraint
|
||
surfacing, group-sources replace idempotence.
|
||
|
||
Acceptance: fmt/ast clean; temp-root green; `zig build test` green (export/import tests
|
||
prove the frozen surface).
|
||
|
||
### W2 As built
|
||
|
||
New shared `crud.zig`: `execStrict` turns zero-rows-touched into `error.NotFound`. Accepted
|
||
deviations: `getX(database, gpa, id) db.Error!?XRow` (rows hold heap strings);
|
||
`insertClientRow`/`insertRuleRow` take `now_s` (NOT NULL columns, repos take no Io — the
|
||
InsertContext.now mirror); client writes split `ClientInput{ip,name,group_id}` (create) vs
|
||
`ClientEdit{name,group_id}` (edit) — **ip is not editable**: it is the identity upsertSeen
|
||
matches, and ruling 9 promises only name/group on PUT; client_prefixes get list + replace
|
||
only (whole-list resource, per-id would be unused generality); write shapes carry group_id
|
||
(a missing group surfaces as the FK violation → 409, not an id-map miss); added
|
||
`listGroupSourceIds` (the PUT needs a read counterpart; frozen listGroupSources speaks
|
||
names). Constraint map for W7: deleting a group with clients → Constraint (clients.group_id
|
||
has no ON DELETE; rules/prefixes/group-sources cascade); bad group_id on client/rule writes
|
||
→ Constraint; url/ip/zone/name uniques → Constraint; deletes of clients/upstreams/rules/
|
||
local-records/forward-zones can only be NotFound. `setGroupSources`: BEGIN IMMEDIATE,
|
||
existence check first (empty set must not report success for a missing group), delete +
|
||
distinct insert (set semantics dedupe); `replaceClientPrefixes` transactional whole-table,
|
||
duplicate prefix REJECTED as Constraint (two rows for one prefix is a contradiction, not a
|
||
set). Schema facts: `upstreams.tls_name` comes from migration v2 (config_schema.zig is v1
|
||
only!) — the openapi /api/upstreams entry needs the fifth field; `SourceRow` gained
|
||
`is_suggested: bool = false` appended last (manager.zig literals compile unchanged, no
|
||
column index moved). `putSetting` is a primary-key upsert — partial settings PUT needs no
|
||
read-first and fires no constraint. 45 new tests + 3 crud.
|
||
|
||
---
|
||
|
||
## Session W3: web core — server, router, http plumbing
|
||
|
||
Owns: `src/web/server.zig`, `src/web/router.zig`, `src/web/http_util.zig`.
|
||
|
||
- server.zig: `WebState` (ruling 26 — declare the struct; fields it cannot yet point at get
|
||
wired by W9), `pub fn serve(state: *WebState, io: std.Io) std.Io.Cancelable!void` — bind
|
||
per ruling 5 (bind failure: warn and return — the DNS side must keep serving; app treats
|
||
web bind failure as non-fatal, W9 documents), accept loop per ruling 4 with the Stop
|
||
split, connection budget per ruling 7 (503 over cap), per-connection buffers (8 KiB recv
|
||
/ 4 KiB send), keep-alive loop, dispatch into router.
|
||
- router.zig: `RouteInfo { method: http.Method, pattern: []const u8, auth: enum {open,
|
||
session}, handler: *const fn(...) }`; `pub const routes: []const RouteInfo` (ruling 23
|
||
depends on it); match = exact segments + one `{id}` numeric capture; 405 with Allow when
|
||
the path matches another method; target copied before body reads (ruling 25); query-pair
|
||
iterator + percent-decode helpers (in http_util, pure, tested hard: '+', '%2F', truncated
|
||
%, overlong values → 400).
|
||
- http_util.zig: JSON respond helpers (`std.json.Stringify` streaming into a
|
||
`Writer.Allocating` for content-length, or direct respond for small bodies), the error
|
||
envelope, request-body reader with the 1 MiB cap (413), cookie parse/format, bearer of
|
||
nothing else.
|
||
- The connection handler calls: limiter (W4, via a comptime-checkable interface field on
|
||
WebState so W3 compiles before W4 lands — define `ApiLimiter` and `Sessions` as W3-owned
|
||
INTERFACE structs? NO: simpler, W3 declares the WebState fields as `*auth.Sessions` /
|
||
`*auth.ApiLimiter` types and W3 is built AFTER W4's file exists on disk in the same wave
|
||
— to keep the wave parallel, W3 instead keeps auth/limiter checks behind two function
|
||
pointers on WebState (`check_auth`, `check_limit`) that W9 wires; W3 tests them with test
|
||
doubles. This is the one seam where indirection is warranted; document it.)
|
||
- Tests: router matching table, 405/404, query decoding, body cap, cookie round trip,
|
||
keep-alive across two requests (loopback socket), 503 over cap, cancel-during-idle
|
||
keep-alive returns promptly.
|
||
|
||
Acceptance: fmt/ast clean; temp-root green (may import std only + own files); no
|
||
listener/handler file touched.
|
||
|
||
### W3 As built
|
||
|
||
Handler signature: `fn(state: *server.WebState, io: std.Io, request: *http_util.Request)
|
||
http_util.HandlerError!void`, `HandlerError = {WriteFailed, HttpExpectationFailed,
|
||
OutOfMemory}` — domain outcomes are status codes, only those three escape. `Request` folds
|
||
the id capture in (`request.id: ?i64`; no separate params type) and carries a per-request
|
||
arena. `RouteInfo` gained `rate_limit: enum{counted,exempt} = .counted` (ruling 19's
|
||
exemptions as data, not path matching); WebState gained `fallback: ?HandlerFn` (SPA
|
||
fallback is not a route entry; unmatched non-/api → fallback, unmatched /api → JSON 404)
|
||
and `reload_fn` (W7's ruling-12 seam). routes.zig: W8 fills `pub const table`; router
|
||
re-exports as `routes`; WebState.routes defaults to it, so the drift guards read the array
|
||
the server matches. W4/W5 landed mid-session, so sessions/limiter/hub/sink are REAL typed
|
||
pointers and check_auth/check_limit default to real implementations (`sessionAuth`,
|
||
`bucketLimit`; `allowAll`/`neverLimit` exported as test doubles) — a half-wired server
|
||
fails closed. serve returns void (cancellation is classified into the Stop enum;
|
||
tcp_server precedent). Own validating percent-decoder — std.Uri's copies malformed escapes
|
||
through as literal text (Uri.zig:172-192), ruling 29 wants 400; segments split before
|
||
decode so %2F cannot forge a boundary. Loopback tests live in web/server_integration_test
|
||
.zig (house gating pattern). Accepted tradeoff: over-capacity 503 is written and the
|
||
socket closed without draining, so the client may see RST instead — draining would be an
|
||
unbounded blocking read on an at-capacity accept loop (documented). RULINGS RECORDED:
|
||
W7 may add the `local_tables` field + import to server.zig (two-line exception, W3
|
||
consents); Retention.stats is a cross-task data race — W6 owns storage/retention.zig
|
||
additively this wave: atomic counters + `snapshotStats` (logger-counters pattern), and
|
||
/metrics reads only through it. 39 tests.
|
||
|
||
**Amendment (post-W9 critical fix)**: `serveConn` normalizes the request head immediately
|
||
after `receiveHead`: a body-carrying method (POST/PUT/PATCH) with neither Content-Length
|
||
nor Transfer-Encoding gets `head.content_length = 0` before any dispatch or respond.
|
||
RFC 9110 §8.6 defines such a request as having an empty body, but std leaves the head
|
||
saying "unknown" and `respond` → `discardBody` (http/Server.zig:631) asserts one of the
|
||
two is set — so a bare `curl -X POST` panicked the whole process, DNS included. Zero
|
||
content-length satisfies every downstream path: `bodyReader` (http.zig:445) goes straight
|
||
to `.ready`, `discardRemaining` sees EndOfStream, keep-alive survives, and
|
||
`http_util.readBody` yields an empty slice. Regression test in
|
||
server_integration_test.zig: raw POST without either header → well-formed response, empty
|
||
body observed by the handler, fresh connection answered after, connection_errors 0.
|
||
|
||
---
|
||
|
||
## Session W4: auth + API limiter
|
||
|
||
Owns: `src/web/auth.zig`, `src/web/api_limiter.zig`, plus the `model.zig`/`validate.zig`
|
||
additions for `web.api_localhost_exempt` (ruling 19) — model field, validate rule (none
|
||
needed beyond type), settings key list, and the export/import round-trip expectations
|
||
(check model.toSettings reflection picks it up automatically; add the settings-count test
|
||
adjustments).
|
||
|
||
- auth.zig: `Sessions` fixed 32-slot store per ruling 17 (create → cookie value out;
|
||
validate cookie → bool; logout; clearAll; expiry sweep on access; LRU evict), login
|
||
verify via argon2 strVerify (io + allocator required — argon2.zig:600), token =
|
||
32 bytes randomSecure → url_safe_no_pad; storage = SHA-256 digest; compare
|
||
timing_safe.eql([32]u8,...). `authEnabled(cfg) bool`. Pure unit tests with fixed tokens
|
||
(seed io.random? no — inject the token bytes via a testable `createWithToken`).
|
||
- api_limiter.zig: token bucket per ruling 19, fixed 4096-key table (house idiom), awake
|
||
clock, `check(now, key, is_localhost) Result{allowed, retry_after_s}`; localhost exempt
|
||
when configured; `sse_connections` per-IP counter with `tryAcquireSse/releaseSse` against
|
||
`sse_max_connections_per_ip`. Tests: refill math at boundaries, burst=capacity, exempt
|
||
localhost, sse cap acquire/release, eviction.
|
||
|
||
Acceptance: fmt/ast clean; temp-root green; `zig build test` green (model/validate/export
|
||
tests still pass with the new field).
|
||
|
||
### W4 As built
|
||
|
||
Sessions: digests only (SHA-256), full-slot scan on validate so work does not depend on
|
||
match position; `verifyPassword` returns `Outcome{ok,denied,unavailable}` — `.unavailable`
|
||
(broken PHC string, OOM) maps to 500, not 401; nothing secret is ever formatted (warn
|
||
prints `@errorName` only). `createWithToken`/`validateAt` are the test seams. Cookie value
|
||
is 43 chars url_safe_no_pad; `cookie_attributes = "HttpOnly; SameSite=Lax; Path=/"`.
|
||
Limiter deviations, accepted: (1) `check(io, now, addr)` — the limiter holds its OWN
|
||
std.Io.Mutex (W3 dispatches connections concurrently; one lock inside beats N outside) and
|
||
derives loopback itself via exported `isLoopback`, so callers cannot disagree; (2) refill
|
||
counts millionths of a token and advances the clock only by the span converted, so the
|
||
truncated remainder survives — at 1 req/min the 60 s boundary is exact (tests at 999/1000
|
||
ms); (3) the SSE per-IP cap binds loopback clients too — `api_localhost_exempt` exempts
|
||
the RATE, not the fixed hub-slot resource. Full table → unknown addresses allowed +
|
||
`untracked` (DNS-limiter precedent); `sweep` drops only full, SSE-free, window-idle
|
||
buckets; `retry_after_s` never 0 on refusal. Model ripple: `web.api_localhost_exempt` in
|
||
Web + expected_keys + fixture; validate.zig needed no edit (bool; decodeValue already
|
||
rejects non-bool text); no count assertion outside W4 files existed. 12 auth + 14 limiter
|
||
tests.
|
||
|
||
---
|
||
|
||
## Session W5: query sink, SSE hub, logger split
|
||
|
||
Owns: `src/server/query_sink.zig` (new), `src/web/sse.zig` (new), `src/storage/logger.zig`
|
||
(additive split ONLY: `transformed(entry) Entry` pure + `logTransformed(io, entry)`;
|
||
existing `log` becomes transform+logTransformed with byte-identical behavior — the
|
||
existing tests must pass unchanged), and `src/server/handler.zig` (mechanical: field
|
||
`logger: ?*logger_mod.Logger` → `sink: ?*query_sink.QuerySink`, the log call site, tests
|
||
updated to construct a sink around their logger).
|
||
|
||
- query_sink.zig per ruling 20: publish BEFORE enqueue (PLAN:455).
|
||
- sse.zig `Hub`: 32 slots × 64-entry Entry rings + Event per subscriber;
|
||
`subscribe() ?SubscriberId` / `unsubscribe(id)`; `publish(io, entry)` copies the entry
|
||
into every live ring (Entry is self-contained, ~400 B; a full ring marks the subscriber
|
||
`overflowed` and sets its event — the subscriber task sees the flag and ends the
|
||
response); `wait(id, timeout)` for the heartbeat loop. All under one mutex; publish is
|
||
called on the DNS hot path — it must stay allocation-free and short (copy + flag set).
|
||
Tests: publish/consume order, overflow disconnect flag, unsubscribe under load,
|
||
heartbeat timeout returns empty.
|
||
- Handler tests: one added test proving the sink publishes and logs (tiny hub + logger).
|
||
|
||
Acceptance: fmt/ast clean; temp-root green including ALL existing handler and logger
|
||
tests; `zig build test` + `-Dintegration` green (phase7 tests construct Handlers — they
|
||
compile against the renamed field; update them, they are in your ownership for THIS
|
||
mechanical change only — list every touched line in the report).
|
||
|
||
### W5 As built
|
||
|
||
Every Hub method takes `io` (the mutex needs it); all locking is `lockUncancelable`
|
||
(milestone-7 tracker precedent: `handle` has no error union). `Hub.init(self) void`
|
||
initializes IN PLACE — 32×64 rings ≈ 900 KiB, so **W9 must `gpa.create(Hub)`, never a
|
||
stack local**. Read surface ruling 20 left open, as built: `next(io, id) ?Entry` (pop
|
||
oldest), `overflowed(io, id) bool` (sticky; pre-overflow entries stay readable — the
|
||
subscriber drains then disconnects), `wait(io, id, timeout) Cancelable!Wake` with
|
||
`Wake = {ready, timeout}`; `wait` resets the event under the mutex only while the ring is
|
||
empty, so a racing publish is never lost, and a spurious futex wake reports `.timeout`
|
||
(costs one heartbeat). Publish-before-persist is pinned structurally (enqueue never
|
||
blocks, so a clock cannot observe order): the test closes the logger queue and the entry
|
||
still reaches the hub while counting `queries_dropped`. W5 also made the minimal app.zig
|
||
compile fix (sink around the logger, hub = null, `.sink = &sink`) — **W9: the sink already
|
||
exists in app.zig; only the hub and the rest of the wiring are missing.** 11 sse + 4 sink
|
||
+ 1 logger-split + 1 handler test; all pre-existing logger/handler tests unchanged.
|
||
|
||
---
|
||
|
||
## Session W6: read handlers + metrics + health + version + openapi route
|
||
|
||
Owns: `src/web/handlers/stats.zig`, `queries.zig`, `lookup.zig`, `upstream_health.zig`,
|
||
`health.zig`, `version.zig`, `metrics.zig` (in web/, not handlers/ — PLAN layout :227).
|
||
|
||
Implement rulings 11, 13, 14, 21, 22; `GET /api/version` = version.zig string + git commit
|
||
build option. Every handler is `fn(state, io, request-ish, params) !void` matching W3's
|
||
handler signature; JSON via http_util. Metrics: mind the mutex discipline (cache/limiter
|
||
stats under the handler's mutexes; pool snapshot copies borrowed strings immediately;
|
||
atomics via .monotonic loads). Handlers read the querylog via state's own connection.
|
||
Tests: pure formatting tests per handler where the data is injectable (metrics text
|
||
golden test, health degraded matrix, stats bucket math via W1 fixtures on an in-memory
|
||
db).
|
||
|
||
Acceptance: fmt/ast clean; temp-root green.
|
||
|
||
### W6 As built
|
||
|
||
Every handler is split into a pure core plus a thin `handle`, so the decisions are tested
|
||
without an `http.Server.Request`: `metrics.collect`/`metrics.render`, `health.collect`/
|
||
`rollup`, `stats.window`/`periodParam`, `queries.parseFilter`/`page`, `lookup.evaluate`/
|
||
`body`, `upstream_health.collect`, `version.body`. Entry points for W8's table:
|
||
`metrics.handle`, `stats.totals`, `stats.timeseries`, `queries.list`, `lookup.handle`,
|
||
`upstream_health.handle`, `health.handle`, `version.handle` — all match `router.HandlerFn`
|
||
(proven by a temp-root assignment). `/metrics` and `/api/health` are `auth = .open,
|
||
rate_limit = .exempt`; `/api/version` is `.open, .counted`; the other four are `.session,
|
||
.counted`. **W8 must add `_ = @import("web/metrics.zig")` and the six handler files to
|
||
src/tests.zig** — this session did not wire the orchestrator's file.
|
||
Retention (ruled in W3's As built): `Stats` is now the plain snapshot type and the live
|
||
counters moved to a private `Counters` of `std.atomic.Value(u64)` in `Retention.counters`;
|
||
`snapshotStats()` reads them `.monotonic`; `runOnce` derives its pass number from the
|
||
`fetchAdd` result rather than re-reading. Test assertions changed mechanically at
|
||
retention.zig lines 192-195, 215, 220, 234, 238-240, 243-244, 258-260, 283-286, 312-313 and
|
||
at phase6_integration_test.zig lines 365-368 (`x.stats.f` → `x.snapshotStats().f`) — that
|
||
second file is the only consumer outside retention.zig, and it compiles solely under
|
||
`-Dintegration`, so the plain suite does not catch a break there. No behavior changed.
|
||
Decisions and deviations, all accepted:
|
||
- `/metrics` carries the DNS counters as an ARRAY keyed by `Handler.Stats`'s comptime field
|
||
list, so a new counter in the handler appears in the exposition with no edit here; the
|
||
same `counterGroup` helper walks every plain stats struct. Missing collaborator = the
|
||
whole family is omitted (a zero would read as health, an absent series reads as a gap).
|
||
- Added beyond ruling 21's list: `nxdns_disk_sample_failures_total` (a failure mode that
|
||
ruling 22 already surfaces) and `nxdns_upstream_enabled` (an upstream that is down and
|
||
one that is switched off are different operator problems). Logger `writer_failed` is
|
||
deliberately NOT a metric: it is in `/api/health` and would need a gauge family of one.
|
||
- `metrics.poolSnapshot` is the one place that copies pool health, shared with the health
|
||
rollup: cancel protection around `Pool.snapshot` (server.zig's precedent), then every
|
||
borrowed string duped into the request arena immediately. Cache/limiter stats are read
|
||
under `Handler.cache_mutex`/`limiter_mutex` with `lockUncancelable` — `HandlerError` has
|
||
no `Canceled`, so the W5 precedent applies.
|
||
- Ruling 13's window: `until` = end of the bucket `now` falls in, `since = until - width ×
|
||
count`, both on the absolute epoch grid (every width divides 86 400, asserted at
|
||
comptime), so `/api/stats` and `/api/stats/timeseries` cover the identical span — tested
|
||
by summing the buckets against the totals. Both bodies also carry `period`, `since` and
|
||
`until` (a chart cannot label an axis without them); the timeseries body adds
|
||
`bucket_seconds`. **openapi.yaml must document those fields.**
|
||
- `/api/queries`: `limit` outside 1..1000 is a 400 rather than a silent clamp, `before` must
|
||
be positive, and each bad parameter has its own message. `next_before` is the last row's
|
||
id only on a full page. `domain=`/`client=` empty read as absent.
|
||
- `/api/lookup` resolves `group_id` through `groupIndexById`; an id no snapshot group has is
|
||
a 400 (`unknown group_id`), a missing manager or snapshot is 503 `no snapshot loaded`, and
|
||
the domain runs through `name.fromText` + `matcher.normalize` (ruling 29). `source_url`
|
||
comes from `sources_repo.getSource` on the config connection keyed by the snapshot's
|
||
source row id — `SourceSets` carries the display name, not the URL; an unreadable row
|
||
leaves the field null rather than failing the lookup. Records and zones are read through
|
||
`state.handler.?.local_tables` (acquire/release bracketed by one defer), NOT a WebState
|
||
field, because that pointer already exists and is the same table W7 swaps.
|
||
- `/api/upstream/health` reports url, enabled, available, consecutive_failures,
|
||
total_successes, total_failures, success_rate, last_error, plus `available`/`total`
|
||
rollup counts. No timestamps: they are `awake`-clock stamps and mean nothing to a client.
|
||
- `/api/version` adds `zig_version` and `uptime_seconds` (from `WebState.started_unix`) to
|
||
the version and commit strings.
|
||
- The only logging in this session is one `warn` per failed database read or pool/source
|
||
fault, on the 500 path; nothing is logged for a normal request, and no `std.log.err`.
|
||
40 new tests (metrics 5, stats 8, queries 9, lookup 7, health 4, upstream_health 3,
|
||
version 4); retention's 6 tests unchanged in number and green.
|
||
|
||
---
|
||
|
||
## Session W7: mutation handlers + local_tables + pause + settings + auth handlers
|
||
|
||
Owns: `src/web/handlers/` `groups.zig`, `blocklists.zig`, `rules.zig`, `local.zig`,
|
||
`clients.zig`, `settings.zig`, `pause.zig`, `auth.zig` (login/logout), plus
|
||
`src/server/local_tables.zig` (new, ruling 12) and the `src/server/handler.zig` edit to
|
||
read records/zones through it (second mechanical handler change; coordinate: W5 already
|
||
edits handler.zig — W7 runs AFTER W5 lands; sequence inside wave 2).
|
||
|
||
- local_tables.zig: `LocalTables { lock: std.Io.RwLock, records, zones }` with
|
||
`acquire/release` (shared) and `swap(io, new_records, new_zones)` (exclusive; frees the
|
||
old under the lock after swap — no reader can hold across queries since acquire/release
|
||
brackets each query). Handler: acquire in handle, release on every exit (same single
|
||
defer discipline as the snapshot).
|
||
- Handlers: rulings 9, 12, 15, 16, 17-18 behaviors; every mutation validates (shared
|
||
validators — reuse validate.zig section rules by constructing the candidate row set;
|
||
where validate.zig only does whole-config, extract the per-section check it already has
|
||
— validate.zig is NOT in your ownership: if extraction is needed, report it and use a
|
||
local check this milestone), writes via W2 repos on state's config connection, then
|
||
reload/rebuild per ruling 12. blocklists/update → refreshAll + reload, 202 + status.
|
||
settings PUT: merge, validate whole config, write keys, clear sessions on hash change.
|
||
- Tests: per handler with in-memory dbs and a real Manager where cheap, else assert repo
|
||
effects + reload-called flag via a seam (a `reload_fn` pointer on WebState set by W9;
|
||
tests inject a counter).
|
||
|
||
Acceptance: fmt/ast clean; temp-root green; phase7 integration still green.
|
||
|
||
### W7 As built
|
||
|
||
Eleven files, 4199 lines, 89 tests (local_tables 5, mutations 6, groups 13, blocklists 8,
|
||
rules 7, local 12, clients 9, upstreams 6, pause 7, settings 10, auth 6).
|
||
- **The apply/respond split (house pattern for later waves).** `http_util`'s response
|
||
helpers need a live `*http.Server.Request`, which a unit test cannot construct. Every
|
||
route splits into an `apply*` decision function driven against an in-memory database and
|
||
a thin HTTP wrapper.
|
||
- **`mutations.zig` is a new shared file, owned by W7.** It holds the `Failure` union, the
|
||
constraint map (NotFound → 404, Constraint → 409, validation → 400), the `reload` and
|
||
`swapLocalTables` seams, and the `Bench` test fixture.
|
||
- **`config_lock` lives on WebState** (ruled post-report): connection tasks share one
|
||
config database connection, and `changes()`/`lastInsertRowid()` are connection state that
|
||
`execStrict` reads, so writes serialize through `state.config_lock`.
|
||
- **Per-row validation**: a minimal valid skeleton config plus the one candidate row runs
|
||
the real `validate.validate`; no validator duplicated. Duplicate keys and foreign-key
|
||
violations surface from the database as 409, not 400.
|
||
- **`upstreams.zig` is W7's** (ruling 9 listed the resource; neither ownership list named
|
||
it). It never calls the reload seam, refuses to delete the last enabled upstream (409),
|
||
and reports `restart_required: true`.
|
||
- **Ruled additions**: `GET /api/settings` returns a derived `web.auth_enabled` flag; the
|
||
default group cannot be renamed or deleted (409); a group that still holds clients cannot
|
||
be deleted (409).
|
||
- **handler.zig**: `records`/`zones` replaced by `local_tables: ?*LocalTables = null`;
|
||
`handle` acquires once per query, releases on defer — one query reads one generation of
|
||
records, zones and the filter snapshot together. `Context` gained `records`/`zones`.
|
||
- **Ruled mechanical ripples**: web/server.zig two-line exception (import + WebState
|
||
field, plus the config_lock field above); app.zig minimal fix (`var tables: LocalTables
|
||
= .empty` + build calls — **W9 must review**); udp/tcp/resolver integration tests lost
|
||
their `empty_records`/`empty_zones` consts and two `bareHandler` initialisers; phase7
|
||
test converted to real tables where needed.
|
||
- **Routes for W8** (all match W3's signature; id routes read `request.id.?`): groups
|
||
`list get create update remove getSources putSources`; blocklists `list get create
|
||
update remove refresh` (refresh = POST /api/blocklists/update, 202 + status snapshot);
|
||
rules `list get create update remove`; local `listRecords getRecord createRecord
|
||
updateRecord removeRecord listZones getZone createZone updateZone removeZone`; clients
|
||
`list get update remove listPrefixes putPrefixes` (no create); upstreams `list get
|
||
create update remove`; pause `get post`; settings `get put`; auth `login logout`.
|
||
|
||
---
|
||
|
||
## Session W8: static assets, build pipeline, openapi.yaml, route table completion
|
||
|
||
Owns: `src/web/static.zig`, `src/web/openapi.zig`, `src/web/openapi.yaml`,
|
||
`web/dist-placeholder/` (index.html + favicon.svg), `build.zig` (the -Dweb-dist option +
|
||
asset module generation), and the final `router.zig` route-table registration IF W3 left
|
||
registration data-driven (coordinate: W3 owns router.zig — W8 only ADDS the route array
|
||
entries file `src/web/routes.zig` if W3 designed it that way; otherwise W8 hands W3's
|
||
owner a patch — resolve by making W3 expose `routes` as a separate file owned by W8 from
|
||
the start; W3 must read this paragraph).
|
||
|
||
Ruling 24 in full: asset module via addWriteFiles + generated index; etag comptime hash;
|
||
gzip sibling serving; SPA fallback; --web-dev disk serving (cli flag lands in W9 — W8
|
||
exposes `serveFromDisk(dir, ...)`). openapi.yaml documents EVERY route in the table with
|
||
schemas matching the handlers' JSON (snake_case, error envelope, auth markers, 429/401
|
||
responses). Tests: static matching, 304 flow, gzip negotiation, SPA fallback vs /api 404,
|
||
placeholder embeds and serves.
|
||
|
||
Acceptance: fmt/ast clean; `zig build` with default dist green; temp-root green.
|
||
|
||
### W8 As built
|
||
|
||
Seven files: static.zig 362, openapi.zig 66, routes.zig 194 (55 routes), openapi.yaml
|
||
2255, handlers/live.zig 184 (ruled mid-session — no session owned the SSE HTTP handler),
|
||
tools/gen_web_assets.zig 201 (new build tool, W8's), web/dist-placeholder (real page:
|
||
fetches /api/health + /api/version, renders status/upstreams/disk). 21 new tests
|
||
(static 9, openapi 3, routes 6, live 3).
|
||
- **Asset pipeline**: gzip cannot run in the build system itself, so
|
||
`tools/gen_web_assets.zig` runs as a build step: dist staged through WriteFiles (Run
|
||
hashes only a directory arg's path string; staging bakes the content hash into the
|
||
path, so edits invalidate — proven), tool output merged with generated `.gz` siblings
|
||
and `assets.zig` into the `web_assets` anonymous import (exe, cross exes, tests).
|
||
- **`web_assets` module surface**: `File{path, bytes, content_type, etag}`,
|
||
`files: []const File`; root is the generated assets.zig.
|
||
- **ETag** computed in the tool, not comptime (same build-time constant; avoids the
|
||
comptime interpreter hashing large M9 bundles). SHA-256/128-bit, quoted, strong.
|
||
`.gz` siblings are their own `files` entries with their own ETag; direct `*.gz` paths
|
||
are not addressable. The tool skips gz when it does not shrink or below 128 bytes.
|
||
- **static.zig surface for W9**: `state.fallback = static.fallback` (embedded) or wrap
|
||
`static.serveFromDisk(dir, io, request)` in a HandlerFn for `--web-dev` (app.zig owns
|
||
the dir storage; no cache headers in dev mode). Traversal guard in `diskRelativePath`.
|
||
- **live.zig** (SSE, ruling 20): respondStreaming with empty buffer, `retry: 3000`,
|
||
`event: query` frames, 15 s heartbeat (awake clock) via `Hub.wait`, per-IP cap via
|
||
`tryAcquireSse`/`releaseSse`, ring overflow ends the stream with a clean chunked
|
||
terminator so EventSource reconnects. RULED: route is `.session, .exempt` (overrides
|
||
ruling 19's connect-token line; the per-IP SSE cap is the guard, loopback included).
|
||
RULED: the SSE payload omits `id` (a live entry precedes persistence); a comptime test
|
||
pins the remaining nine field names to `QueryRow`.
|
||
- **Route policy**: open = {/metrics, /api/health, /api/version, /api/openapi.yaml,
|
||
/api/auth/login}; limiter-exempt = {/metrics, /api/health, /api/queries/live}; logout
|
||
is `.session` (ruling 18).
|
||
- **openapi.yaml** documents all 55 routes, cookie security scheme, 401 on every session
|
||
route, 429 + Retry-After on every counted route, the W6 stats fields (period, since,
|
||
until, bucket_seconds), SSE as text/event-stream; no certs/reload. openapi.zig's test
|
||
enforces route ⊆ yaml at unit level; W10 still owns the count-equality drift guard (b).
|
||
|
||
---
|
||
|
||
## Session W9: app + cli wiring
|
||
|
||
Owns: `src/app.zig`, `src/cli.zig`, `src/main.zig` (if forced).
|
||
|
||
WebState assembly (ruling 26) gated on `web.enabled`; two extra DB connections; hub +
|
||
sink construction (sink wraps logger always — hub only when web enabled; DNS path cost
|
||
without web = one null check); local_tables construction replacing the direct
|
||
records/zones locals; web serve task in the group (started last); reload_fn/check seams
|
||
wired; `--web-dev <dir>` CLI flag (parseArgs + help text + runCheck untouched); web bind
|
||
failure = warn + continue serving DNS (ruling in W3 — confirm and document in app).
|
||
Shutdown order unchanged (web task dies with group.cancel; its connection group cancels
|
||
per ruling 4). Smoke test yours: boot with web enabled, curl /api/health, /api/version,
|
||
/metrics, /, login flow with a password set (import a config with web.password, verify
|
||
cookie + 401 without), SIGTERM exit 0. Report the transcript.
|
||
|
||
Acceptance: fmt/ast clean; zig build; both test suites green; smoke transcript.
|
||
|
||
### W9 As built
|
||
|
||
- `cli.Command.run` payload is now `RunArgs { paths: Paths, web_dev: ?[]const u8 }`;
|
||
`parseRunArgs` handles `--web-dev DIR` (both spellings); `check` rejects it;
|
||
`runCheck` untouched. Anything spawning `app.run` passes `RunArgs`.
|
||
- app.zig serve order: zero-guard (`web.api_rate_limit_per_min`/`session_ttl_hours` zero
|
||
on an enabled web → `error.BadRateLimit`, exit 2 — collaborators assert nonzero and a
|
||
hand-edited database bypasses validate); heap `sse.Hub` via `gpa.create` + in-place
|
||
init (W5 rule), gated on `web.enabled`; sink wraps `(&logger, hub)` ALWAYS (hub null
|
||
without web = one branch); two web DB connections (openConfigDb + reopenQuerylogDb,
|
||
after openQuerylogDb establishes the file), gated; Sessions + ApiLimiter, gated;
|
||
WebState assembled after the DNS handler (`reload_fn = app.reloadManager`,
|
||
`fallback = static.fallback` or the dev wrapper, version.string, started_unix); web
|
||
task added to the group LAST; bind failure = warn + return, DNS keeps serving.
|
||
Shutdown order unchanged. `web.enabled = false` → no hub, no web DBs, no
|
||
sessions/limiter, no web task.
|
||
- `--web-dev` dir lives in a file-scope var in app.zig (`WebState.fallback` is a bare fn
|
||
pointer, no closure; one composition root; written once before the task starts).
|
||
- W7's `var tables: LocalTables` holder confirmed correct: `WebState.local_tables`
|
||
points at the same holder the DNS handler reads, so `swapLocalTables` swaps both.
|
||
- Mechanical ripple: phase7 test line 967 `cli.Paths{...}` → `RunArgs{ .paths = ... }`.
|
||
- Smoke test passed end to end: import with web.password → boot → 200 on /api/health,
|
||
/api/version, /metrics, /, /api/openapi.yaml → 401 without cookie → login sets
|
||
HttpOnly/SameSite=Lax cookie → authed read → logout kills the cookie → wrong password
|
||
401 → SIGTERM exit 0. No secrets in logs. `--web-dev` smoked: disk edits appear
|
||
without restart.
|
||
- **Critical finding (fixed post-session in W3's files)**: a bodyless POST/PUT — no
|
||
Content-Length, no Transfer-Encoding, exactly `curl -X POST` — hit the
|
||
`discardBody` assert in std (Server.zig:631) and panicked the process, DNS included.
|
||
RFC 9110 gives such a request an empty body, so the fix normalizes in the request
|
||
path before any respond call (see W3 As-built amendment); W10 pins it with a contract
|
||
test.
|
||
|
||
---
|
||
|
||
## Session W10: integration + contract tests + CI
|
||
|
||
Owns: `src/web/web_integration_test.zig` (new), `.gitea/workflows/ci.yml` (additive job
|
||
steps only).
|
||
|
||
Contract table per ruling 23 covering every route; auth on/off matrix (seeded
|
||
password_hash vs empty); SSE test (connect, cause one query via a real Handler or direct
|
||
sink.publish, assert `event: query` frame + heartbeat + cap enforcement via
|
||
sse_max_connections_per_ip=1); rate-limit 429 + Retry-After; pagination walk; mutation →
|
||
reload observed (snapshot generation bump); pause via API affects a real handler decision;
|
||
settings PUT round trip; static + ETag 304; drift guards (a) and (b). CI: ensure the
|
||
integration job still covers everything (contract tests ride -Dintegration; add a
|
||
`zig build test -Dintegration` step only if missing — read the current yml first).
|
||
|
||
Acceptance: `zig build test -Dintegration` green with zero err lines; plain suite green.
|
||
|
||
### W10 As built
|
||
|
||
web_integration_test.zig, 1406 lines, 14 tests: 11 integration-gated (contract walk over
|
||
all 55 routes with strict `ignore_unknown_fields = false` response shapes, auth on, auth
|
||
off, 429 + Retry-After, SSE, pagination walk, mutation → reload, pause, settings round
|
||
trip, static/ETag 304, bodyless POST) + 3 ungated pure guards (contract-covers-routes,
|
||
drift guard a route ⊆ yaml, drift guard b: 55 yaml operations == router.routes.len).
|
||
ci.yml UNTOUCHED — its test job already runs `zig build test -Dintegration` (line 25).
|
||
- Environment fully real except two seams: real Manager/reload over an in-memory config
|
||
DB, real Sessions/ApiLimiter/Hub (heap Hub per W5 rule), seeded querylog via
|
||
BatchWriter. The pool transport and fetcher are never invoked —
|
||
POST /api/blocklists/update runs in the walk BEFORE any source row exists (hermetic).
|
||
- SSE reader de-frames chunked transfer coding (the unbuffered SSE writer emits one frame
|
||
as many chunks; std's chunked writer emits each chunk's closing CRLF lazily — reading
|
||
separators eagerly deadlocks until the next heartbeat).
|
||
- Heartbeat asserted against the real 15 s interval (`live.heartbeat_interval` is not
|
||
injectable): the SSE test runs ~15-20 s in a 40 s budget; the integration suite gained
|
||
roughly 45 s of wall clock. Accepted rather than weakening the assertion.
|
||
- Pause test drives Handler.handle directly with real DNS packets (phase7 queryFor
|
||
pattern): blocked → API pause → forwarded (paused_queries bumps) → unpause → blocked.
|
||
- Mutation-reload observed as generation 1 → 2 plus the rule live via GET /api/lookup.
|
||
- Contract markers asserted by lookup into router.routes (method+pattern → auth and
|
||
rate_limit equality, exact one-to-one coverage); response shapes reference the
|
||
handlers' pub types where they exist; the strict settings parse also proves
|
||
`web.password*` never serializes.
|
||
- One expected `warn` line ("web login refused") from the wrong-password case; zero err
|
||
lines suite-wide; no secrets logged.
|
||
|
||
---
|
||
|
||
## Module layout (new)
|
||
|
||
src/web/{server,router,http_util,auth,api_limiter,sse,static,openapi,metrics}.zig,
|
||
src/web/routes.zig (W8), src/web/handlers/{auth,stats,queries,clients,groups,blocklists,
|
||
rules,local,lookup,pause,settings,upstream_health,health,version}.zig,
|
||
src/server/{query_sink,local_tables}.zig, src/web/openapi.yaml, web/dist-placeholder/,
|
||
src/web/web_integration_test.zig.
|
||
|
||
## File ownership
|
||
|
||
W1 queries_repo; W2 other repositories/*; W3 web/{server,router,http_util}; W4
|
||
web/{auth,api_limiter} + model/validate additions; W5 server/query_sink, web/sse,
|
||
storage/logger (additive), server/handler (sink rename) + phase7 test compile fixes; W6
|
||
web/handlers read set + web/metrics; W7 web/handlers mutation set (incl. mutations.zig,
|
||
upstreams.zig) + server/local_tables + server/handler (local_tables read path; AFTER W5); W8 web/{static,openapi,routes} +
|
||
openapi.yaml + dist-placeholder + build.zig; W9 app/cli/main; W10 its test file + ci.yml.
|
||
Orchestrator: src/tests.zig, spec. Within-wave parallel sessions never share a file;
|
||
handler.zig is touched by W5 then W7, strictly sequenced.
|
||
|
||
## Acceptance (milestone complete)
|
||
|
||
- [ ] Both suites + cross green; fmt clean; zero err-level lines in integration output.
|
||
- [ ] Every PLAN:537-550 endpoint except certs/reload (ruling 2) implemented, documented in
|
||
openapi.yaml, and contract-tested; auth on/off matrix green.
|
||
- [ ] SSE live stream works end to end with per-IP cap; fanout precedes persistence.
|
||
- [ ] /metrics scrapes clean (golden test); /api/health rollup correct.
|
||
- [ ] W9 smoke transcript: boot, API answers, login round trip, SIGTERM 0.
|
||
- [ ] Placeholder UI loads at /; assets ETag/304/gzip machinery proven.
|
||
- [ ] Spec As-built synced per session.
|
||
|
||
## Review (Codex, As built)
|
||
|
||
Round 1: 6 important + 3 minor, all fixed.
|
||
- local.zig: all six mutation paths hold `config_lock` across write AND rebuild+swap, so
|
||
LocalTables swaps publish in database-write order (deadlock-checked: no callee takes
|
||
the lock).
|
||
- settings/auth/server/app: GET /api/settings reads under `config_lock`. New
|
||
`auth.LiveHash` (mutex holder on `WebState.live_hash`, generation-checked — see round
|
||
2): login copies the hash out under a short lock and verifies OUTSIDE it; settings PUT
|
||
dupes the new hash with gpa BEFORE the transaction, installs + clears sessions after
|
||
commit; `sessionAuth` gates on `live_hash.enabled()`, so a first password set via PUT
|
||
locks routes without restart. Boot hash borrows the config arena (owned=false);
|
||
replacements are gpa-owned, freed on the next install or deinit (app.zig defer /
|
||
Bench.deinit / Env.destroy).
|
||
- mutations.zig `checkUpstream`: candidate validated beside a companion enabled upstream
|
||
(URL collision-avoided), so disabled upstreams are creatable. upstreams.zig
|
||
`applyUpdate` gained the missing last-enabled guard: disabling the last enabled
|
||
upstream is 409 (delete path already had it; create needs none).
|
||
- clients.zig: prefixes canonicalized via platform/address.zig (parse zeroes host bits;
|
||
RFC 5952 text) before a set-level duplicate check (409, constraint-map ruling) and
|
||
whole-list validation through the real validate.validate; canonical text is stored.
|
||
- api_limiter.zig: full table fails closed — `bucketLocked` first evicts a bucket that
|
||
refills to capacity with sse==0 (behaviorally identical to fresh; sweep's idle window
|
||
is churn hysteresis, not correctness), else `check` refuses with retry_after and
|
||
`tryAcquireSse` returns false. `untracked` is now a subset of `refused`.
|
||
- static.zig: `acceptsGzip` parses every entry per the header grammar (specific gzip
|
||
beats wildcard; malformed q = entry unusable). Dev mode realpath-checks containment of
|
||
target AND index fallback (no-follow-on-open would miss symlinked intermediate
|
||
directories).
|
||
- web_integration_test.zig: drift guard (a) requires the method key inside the specific
|
||
path's yaml block; a negative test doctors the yaml (method swap between two paths)
|
||
and proves the guard bites.
|
||
|
||
Round 2: 1 important — login could copy the old hash, race a password-change PUT
|
||
(install + clearAll), finish argon2 verification, and mint a session with the revoked
|
||
password. Fixed via a generation counter on LiveHash: applyLogin verifies the copy
|
||
outside any lock, then `confirmSession` checks the generation and inserts the session
|
||
digest under LiveHash.mutex (lock order: live_hash → sessions, single direction at every
|
||
site); a changed generation denies the login.
|
||
|
||
Round 3: 2 minors, both fixed. (a) install-then-clearAll had a gap where a legitimate
|
||
new-password cookie could be minted and then wiped — `install` is replaced by
|
||
`installAndRevoke(io, gpa, sessions, new_hash)`: swap, generation bump, and nested
|
||
clearAll in one LiveHash-ordered operation; a confirm either precedes it (wiped) or
|
||
follows it (stale generation, denied). (b) confirmSession no longer holds LiveHash.mutex
|
||
across entropy/clock work: token (randomSecure, zeroed on exit) and timestamp are
|
||
generated before the lock; only the generation check + `createWithToken` digest insert
|
||
run under the ordered locks. `Sessions.create` removed (createWithToken is the seam).
|
||
|
||
Round 4: no findings.
|
||
|
||
## Anti-requirements
|
||
|
||
- No React/SPA content (milestone 9). No DoH/DoT server or certs/reload (Phase 9). No
|
||
docs rendering (Phase 10). No WebSocket. No HTTP/2, no TLS on the web port. No external
|
||
schema validator or yaml parser. No new DB schema migration without an explicit
|
||
orchestrator ruling (27). No pause persistence. No per-request timeouts. No changes to
|
||
dns/, filter/matcher, cache, or the DNS pipeline order.
|