From 5253c473039ce8fcbb8d8fb8d0434d0fc815244b Mon Sep 17 00:00:00 2001 From: m5r Date: Sun, 2 Aug 2026 00:54:13 +0200 Subject: [PATCH] milestone 8: web server, rest api, sse, auth, metrics and static assets --- build.zig | 46 +- specs/milestone-8.md | 856 +++++++ src/app.zig | 157 +- src/cli.zig | 58 +- src/config/model.zig | 6 + src/main.zig | 2 +- src/server/handler.zig | 103 +- src/server/local_tables.zig | 217 ++ src/server/phase7_integration_test.zig | 29 +- src/server/query_sink.zig | 148 ++ src/server/resolver_integration_test.zig | 6 - src/server/tcp_server_integration_test.zig | 6 - src/server/udp_server_integration_test.zig | 6 - src/storage/logger.zig | 59 +- src/storage/phase6_integration_test.zig | 8 +- src/storage/repositories/clients_repo.zig | 440 +++- src/storage/repositories/crud.zig | 80 + src/storage/repositories/groups_repo.zig | 353 ++- src/storage/repositories/local_repo.zig | 342 ++- src/storage/repositories/queries_repo.zig | 670 +++++- src/storage/repositories/rules_repo.zig | 241 +- src/storage/repositories/settings_repo.zig | 68 +- src/storage/repositories/sources_repo.zig | 257 ++- src/storage/repositories/upstreams_repo.zig | 203 +- src/storage/retention.zig | 84 +- src/tests.zig | 32 + src/web/api_limiter.zig | 687 ++++++ src/web/auth.zig | 778 +++++++ src/web/handlers/auth.zig | 382 ++++ src/web/handlers/blocklists.zig | 396 ++++ src/web/handlers/clients.zig | 457 ++++ src/web/handlers/groups.zig | 448 ++++ src/web/handlers/health.zig | 236 ++ src/web/handlers/live.zig | 184 ++ src/web/handlers/local.zig | 551 +++++ src/web/handlers/lookup.zig | 345 +++ src/web/handlers/mutations.zig | 538 +++++ src/web/handlers/pause.zig | 176 ++ src/web/handlers/queries.zig | 325 +++ src/web/handlers/rules.zig | 361 +++ src/web/handlers/settings.zig | 650 ++++++ src/web/handlers/stats.zig | 340 +++ src/web/handlers/upstream_health.zig | 173 ++ src/web/handlers/upstreams.zig | 365 +++ src/web/handlers/version.zig | 72 + src/web/http_util.zig | 454 ++++ src/web/metrics.zig | 611 +++++ src/web/openapi.yaml | 2255 +++++++++++++++++++ src/web/openapi.zig | 66 + src/web/router.zig | 269 +++ src/web/routes.zig | 194 ++ src/web/server.zig | 733 ++++++ src/web/server_integration_test.zig | 648 ++++++ src/web/sse.zig | 439 ++++ src/web/static.zig | 447 ++++ src/web/web_integration_test.zig | 1468 ++++++++++++ tools/gen_web_assets.zig | 201 ++ web/dist-placeholder/favicon.svg | 5 + web/dist-placeholder/index.html | 59 + 59 files changed, 19640 insertions(+), 150 deletions(-) create mode 100644 specs/milestone-8.md create mode 100644 src/server/local_tables.zig create mode 100644 src/server/query_sink.zig create mode 100644 src/storage/repositories/crud.zig create mode 100644 src/web/api_limiter.zig create mode 100644 src/web/auth.zig create mode 100644 src/web/handlers/auth.zig create mode 100644 src/web/handlers/blocklists.zig create mode 100644 src/web/handlers/clients.zig create mode 100644 src/web/handlers/groups.zig create mode 100644 src/web/handlers/health.zig create mode 100644 src/web/handlers/live.zig create mode 100644 src/web/handlers/local.zig create mode 100644 src/web/handlers/lookup.zig create mode 100644 src/web/handlers/mutations.zig create mode 100644 src/web/handlers/pause.zig create mode 100644 src/web/handlers/queries.zig create mode 100644 src/web/handlers/rules.zig create mode 100644 src/web/handlers/settings.zig create mode 100644 src/web/handlers/stats.zig create mode 100644 src/web/handlers/upstream_health.zig create mode 100644 src/web/handlers/upstreams.zig create mode 100644 src/web/handlers/version.zig create mode 100644 src/web/http_util.zig create mode 100644 src/web/metrics.zig create mode 100644 src/web/openapi.yaml create mode 100644 src/web/openapi.zig create mode 100644 src/web/router.zig create mode 100644 src/web/routes.zig create mode 100644 src/web/server.zig create mode 100644 src/web/server_integration_test.zig create mode 100644 src/web/sse.zig create mode 100644 src/web/static.zig create mode 100644 src/web/web_integration_test.zig create mode 100644 tools/gen_web_assets.zig create mode 100644 web/dist-placeholder/favicon.svg create mode 100644 web/dist-placeholder/index.html diff --git a/build.zig b/build.zig index 03b79c7..28f28f2 100644 --- a/build.zig +++ b/build.zig @@ -21,6 +21,14 @@ pub fn build(b: *std.Build) void { const fuzz = b.option(bool, "fuzz", "Build the fuzz targets with the LLVM backend (required for --fuzz)") orelse false; const version_string = b.option([]const u8, "version-string", "Version reported by `nxdns version`") orelse "0.1.0-dev"; const git_commit = b.option([]const u8, "git-commit", "Git commit reported by `nxdns version`") orelse "unknown"; + const web_dist = b.option([]const u8, "web-dist", "Built web UI directory to embed (default: the placeholder page)") orelse "web/dist-placeholder"; + + // `b.path` panics on absolute paths, and a CI artifact directory is one. + const web_dist_path: std.Build.LazyPath = if (std.fs.path.isAbsolute(web_dist)) + .{ .cwd_relative = web_dist } + else + b.path(web_dist); + const web_assets = webAssetsIndex(b, web_dist_path); const options = b.addOptions(); options.addOption(bool, "integration", integration); @@ -29,7 +37,7 @@ pub fn build(b: *std.Build) void { options.addOption([]const u8, "git_commit", git_commit); options.addOption([]const u8, "zig_version_string", builtin.zig_version_string); - const exe = addExecutable(b, target, optimize, options); + const exe = addExecutable(b, target, optimize, options, web_assets); b.installArtifact(exe); const run = b.addRunArtifact(exe); @@ -53,6 +61,7 @@ pub fn build(b: *std.Build) void { tests.root_module.addAnonymousImport("test_fixtures", .{ .root_source_file = b.path("tests/fixtures/fixtures.zig"), }); + tests.root_module.addAnonymousImport("web_assets", .{ .root_source_file = web_assets }); const test_step = b.step("test", "Run the test suite"); test_step.dependOn(&b.addRunArtifact(tests).step); @@ -102,7 +111,7 @@ pub fn build(b: *std.Build) void { const query = std.Target.Query.parse(.{ .arch_os_abi = triple }) catch |err| { std.debug.panic("invalid cross target '{s}': {t}", .{ triple, err }); }; - const cross_exe = addExecutable(b, b.resolveTargetQuery(query), optimize, options); + const cross_exe = addExecutable(b, b.resolveTargetQuery(query), optimize, options, web_assets); cross_exe.linkage = .static; const install = b.addInstallArtifact(cross_exe, .{ .dest_dir = .{ .override = .{ .custom = b.fmt("cross/{s}", .{triple}) } }, @@ -116,6 +125,7 @@ fn addExecutable( target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, options: *std.Build.Step.Options, + web_assets: std.Build.LazyPath, ) *std.Build.Step.Compile { const exe = b.addExecutable(.{ .name = "nxdns", @@ -127,11 +137,43 @@ fn addExecutable( }), }); exe.root_module.addOptions("build_options", options); + exe.root_module.addAnonymousImport("web_assets", .{ .root_source_file = web_assets }); exe.root_module.linkLibrary(sqliteLibrary(b, target, optimize)); exe.root_module.linkLibrary(mbedtlsLibrary(b, target, optimize)); return exe; } +/// The embedded web UI (milestone-8 ruling 24): the dist directory plus the +/// generated `assets.zig` index and build-time gzip siblings, merged into one +/// WriteFiles directory so every `@embedFile` path resolves inside the module +/// root. Returns the index file, the root of the `web_assets` module. +/// +/// The dist is staged through its own WriteFiles step before it reaches the +/// tool because a Run step hashes only the resolved path string of a directory +/// argument, not its contents; the staged copy lives at a content-hashed path, +/// so editing an asset re-runs the tool instead of replaying a stale cache. +fn webAssetsIndex(b: *std.Build, dist: std.Build.LazyPath) std.Build.LazyPath { + const staged = b.addWriteFiles().addCopyDirectory(dist, ".", .{}); + + const tool = b.addExecutable(.{ + .name = "gen_web_assets", + .root_module = b.createModule(.{ + .root_source_file = b.path("tools/gen_web_assets.zig"), + .target = b.graph.host, + .optimize = .ReleaseSafe, + }), + }); + + const run = b.addRunArtifact(tool); + run.addDirectoryArg(staged); + const generated = run.addOutputDirectoryArg("web_assets"); + + const merged = b.addWriteFiles(); + _ = merged.addCopyDirectory(staged, ".", .{}); + _ = merged.addCopyDirectory(generated, ".", .{}); + return merged.getDirectory().path(b, "assets.zig"); +} + /// SQLite 3.53.4 amalgamation (see build.zig.zon for the pinned URL and hash). fn sqliteLibrary( b: *std.Build, diff --git a/specs/milestone-8.md b/specs/milestone-8.md new file mode 100644 index 0000000..55ce406 --- /dev/null +++ b/specs/milestone-8.md @@ -0,0 +1,856 @@ +# 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":""}`. 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=`, ordered id DESC; filters `domain=` (substring), `client=` (exact), + `blocked=` (bool), `since=`/`until=` (unix seconds). Response `{"queries":[...], + "next_before": |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: |null, blocked, reason, matched, source_url|null, + safe_search_rewrite: |null}` via `snapshot.evaluate` + records/zones lookups. + Null snapshot → 503 `{"error":"no snapshot loaded"}`. +15. **Pause API**: `GET /api/pause` → `{"paused": bool, "until": |null}` (null while + unpaused OR indefinite — disambiguated by `paused`). `POST /api/pause` body + `{"paused": true, "duration_seconds": }` 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=`; `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: `. 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: ` 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__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=` (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 `.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 ` 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 ` 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. diff --git a/src/app.zig b/src/app.zig index 37f5976..5ea7105 100644 --- a/src/app.zig +++ b/src/app.zig @@ -30,10 +30,13 @@ const Writer = std.Io.Writer; const net = std.Io.net; const tls = std.crypto.tls; +const api_limiter = @import("web/api_limiter.zig"); +const auth = @import("web/auth.zig"); const bootstrap = @import("config/bootstrap.zig"); const cli = @import("cli.zig"); const clients = @import("server/clients.zig"); const config_export = @import("config/export.zig"); +const db = @import("storage/db.zig"); const disk_monitor = @import("storage/disk_monitor.zig"); const dns_cache = @import("cache/dns_cache.zig"); const doh_client = @import("upstream/doh_client.zig"); @@ -41,7 +44,9 @@ const dot_client = @import("upstream/dot_client.zig"); const fetcher = @import("filter/fetcher.zig"); const forward_zones = @import("local/forward_zones.zig"); const handler = @import("server/handler.zig"); +const http_util = @import("web/http_util.zig"); const local_records = @import("local/records.zig"); +const local_tables = @import("server/local_tables.zig"); const logger_mod = @import("storage/logger.zig"); const logging = @import("platform/logging.zig"); const manager_mod = @import("filter/manager.zig"); @@ -49,14 +54,18 @@ const migrations = @import("storage/migrations.zig"); const model = @import("config/model.zig"); const pause = @import("server/pause.zig"); const pool_mod = @import("upstream/pool.zig"); +const query_sink = @import("server/query_sink.zig"); const rate_limiter = @import("server/rate_limiter.zig"); const retention_mod = @import("storage/retention.zig"); const shutdown = @import("server/shutdown.zig"); +const sse = @import("web/sse.zig"); +const static = @import("web/static.zig"); const tcp_server = @import("server/tcp_server.zig"); const transport = @import("upstream/transport.zig"); const udp_server = @import("server/udp_server.zig"); const validate = @import("config/validate.zig"); const version = @import("version.zig"); +const web_server = @import("web/server.zig"); const log = std.log.scoped(.nxdns); @@ -84,8 +93,8 @@ const ConfigError = error{ BadRateLimit, }; -pub fn run(runner: cli.Runner, paths: cli.Paths) u8 { - const code = serve(runner, paths) catch |err| code: { +pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 { + const code = serve(runner, args) catch |err| code: { runner.err.print("nxdns run failed: {s}\n", .{@errorName(err)}) catch {}; if (isConfigFault(err)) { runner.err.writeAll("run `nxdns check` to see the configuration in full\n") catch {}; @@ -108,9 +117,10 @@ fn isConfigFault(err: anyerror) bool { }; } -fn serve(r: cli.Runner, paths: cli.Paths) !u8 { +fn serve(r: cli.Runner, args: cli.RunArgs) !u8 { const io = r.io; const gpa = r.gpa; + const paths = args.paths; // ----------------------------------------------------------------------- // storage and configuration @@ -154,15 +164,27 @@ fn serve(r: cli.Runner, paths: cli.Paths) !u8 { if (cfg.dns.rate_limit == 0 or cfg.dns.rate_window_seconds == 0) return error.BadRateLimit; + // The API limiter and the session store assert these are nonzero + // (`validate` refuses such a config, but nothing validates a database an + // operator edited by hand), and a fault the operator can fix must exit 2, + // not trip an assertion. + if (cfg.web.enabled and + (cfg.web.api_rate_limit_per_min == 0 or cfg.web.session_ttl_hours == 0)) + { + return error.BadRateLimit; + } + // ----------------------------------------------------------------------- // local answers // ----------------------------------------------------------------------- - var records = try local_records.Records.build(gpa, cfg.local_records); - defer records.deinit(gpa); - - var zones = try forward_zones.Zones.build(gpa, cfg.forward_zones); - defer zones.deinit(gpa); + // Ruling 12: the tables are published through the holder the API swaps, so + // the holder owns them from here on and frees whichever generation is + // current at shutdown. + var tables: local_tables.LocalTables = .empty; + defer tables.deinit(gpa); + tables.records = try local_records.Records.build(gpa, cfg.local_records); + tables.zones = try forward_zones.Zones.build(gpa, cfg.forward_zones); // ----------------------------------------------------------------------- // blocklists @@ -238,6 +260,20 @@ fn serve(r: cli.Runner, paths: cli.Paths) !u8 { defer gpa.free(queue_buf); var query_logger: logger_mod.Logger = .init(cfg.logging, queue_buf); + // Milestone 8 fans every logged query out to the SSE hub as well. The hub + // exists only when the web interface does (ruling 6) — without it the sink + // costs the query path one null check. Its rings are ~900 KiB, so it lives + // on the heap and initializes in place; a by-value init would copy the + // whole of it through this frame. + var hub: ?*sse.Hub = null; + defer if (hub) |hub_ptr| gpa.destroy(hub_ptr); + if (cfg.web.enabled) { + const created = try gpa.create(sse.Hub); + created.init(); + hub = created; + } + var sink: query_sink.QuerySink = .init(&query_logger, hub); + // ----------------------------------------------------------------------- // disk, retention and the remaining connections (ruling 21) // ----------------------------------------------------------------------- @@ -278,6 +314,30 @@ fn serve(r: cli.Runner, paths: cli.Paths) !u8 { var tracker_db = try data.openConfigDb(io); defer tracker_db.close(); + // The web task's own two connections (ruling 26; m7 ruling 21: one SQLite + // connection per task), opened only when the web interface is (ruling 6). + // `reopenQuerylogDb` requires the file `openQuerylogDb` established above. + var web_config_db: ?db.Db = null; + defer if (web_config_db) |*database| database.close(); + var web_querylog_db: ?db.Db = null; + defer if (web_querylog_db) |*database| database.close(); + if (cfg.web.enabled) { + web_config_db = try data.openConfigDb(io); + web_querylog_db = try data.reopenQuerylogDb(io); + } + + var sessions: ?auth.Sessions = if (cfg.web.enabled) .init(cfg.web.session_ttl_hours) else null; + + var web_limiter: ?api_limiter.ApiLimiter = null; + defer if (web_limiter) |*limiter_ptr| limiter_ptr.deinit(); + if (cfg.web.enabled) { + web_limiter = try api_limiter.ApiLimiter.init(gpa, .{ + .rate_per_min = cfg.web.api_rate_limit_per_min, + .localhost_exempt = cfg.web.api_localhost_exempt, + .sse_max_per_ip = cfg.web.sse_max_connections_per_ip, + }); + } + // ----------------------------------------------------------------------- // first snapshot // ----------------------------------------------------------------------- @@ -302,16 +362,55 @@ fn serve(r: cli.Runner, paths: cli.Paths) !u8 { .ecs_mode = cfg.edns.ecs_mode, .forward_read_timeout = .{ .raw = model.readTimeout(cfg.upstream), .clock = .awake }, .manager = &manager, - .records = &records, - .zones = &zones, + .local_tables = &tables, .cache = &cache, .negative_ttl_max = cfg.cache.negative_ttl_max, .limiter = &limiter, - .logger = &query_logger, + .sink = &sink, .pause = &paused, .tracker = &tracker, }; + // ----------------------------------------------------------------------- + // web interface (ruling 26) + // ----------------------------------------------------------------------- + + // 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 + // 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 }; + // 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. + defer web_state.live_hash.deinit(gpa); + if (cfg.web.enabled) web_state = .{ + .gpa = gpa, + .web = cfg.web, + .live_hash = .init(cfg.web.password_hash), + .handler = &h, + .pause = &paused, + .tracker = &tracker, + .manager = &manager, + .pool = &pool, + .monitor = &monitor, + .local_tables = &tables, + .logger = &query_logger, + .retention = &retention, + .sessions = if (sessions) |*s| s else null, + .limiter = if (web_limiter) |*l| l else null, + .hub = hub, + .sink = &sink, + .config_db = if (web_config_db) |*database| database else null, + .querylog_db = if (web_querylog_db) |*database| database else null, + .version = version.string, + .started_unix = std.Io.Clock.real.now(io).toSeconds(), + // Ruling 24: `--web-dev` serves from disk with no cache headers; + // otherwise the embedded assets answer every non-/api miss. + .fallback = if (args.web_dev != null) serveWebDev else static.fallback, + .reload_fn = reloadManager, + }; + const v6_bind = parseBind(r, cfg.dns.bind_ipv6, cfg.dns.port, "dns.bind_ipv6") catch |err| return err; const v4_bind = parseBind(r, cfg.dns.bind_ipv4, cfg.dns.port, "dns.bind_ipv4") catch |err| return err; @@ -373,6 +472,13 @@ fn serve(r: cli.Runner, paths: cli.Paths) !u8 { try group.concurrent(io, clients.Tracker.run, .{ &tracker, io, &tracker_db, gate }); try group.concurrent(io, runMaintenance, .{ &h, io }); + // Started last (ruling 26), canceled by the same `group.cancel`; its inner + // connection group is canceled, not awaited (ruling 4), so an idle + // keep-alive client cannot hold shutdown open. A web bind failure is not + // fatal: `web_server.serve` warns and returns, and the DNS side — the thing + // this box exists for — keeps serving. + if (cfg.web.enabled) try group.concurrent(io, web_server.serve, .{ &web_state, io }); + logStartup(io, &manager, upstreams.active().len, .{ .udp6 = if (udp6) |*s| s.boundAddress() else null, .udp4 = if (udp4) |*s| s.boundAddress() else null, @@ -392,6 +498,35 @@ fn serve(r: cli.Runner, paths: cli.Paths) !u8 { return cli.exit_ok; } +// --------------------------------------------------------------------------- +// web seams +// --------------------------------------------------------------------------- + +/// Ruling 12: mutations to rules, blocklists, groups, clients and prefixes +/// rebuild the blocklist snapshot so the change is live on the next query. A +/// state without a manager has nothing to rebuild. +fn reloadManager(state: *web_server.WebState, io: std.Io) anyerror!void { + const manager = state.manager orelse return; + 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, +/// so an edit shows up on the next reload. +fn serveWebDev( + state: *web_server.WebState, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void { + _ = state; + return static.serveFromDisk(web_dev_dir, io, request); +} + // --------------------------------------------------------------------------- // background maintenance // --------------------------------------------------------------------------- diff --git a/src/cli.zig b/src/cli.zig index 6db7cef..e7b26de 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -57,8 +57,12 @@ pub const CheckArgs = struct { paths: Paths = .{}, config_explicit: bool = false pub const ExportArgs = struct { paths: Paths = .{}, out: ?[]const u8 = null }; pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, force: bool = false }; +/// `web_dev` is milestone-8 ruling 24's `--web-dev `: serve the web +/// interface from that directory instead of the embedded assets. +pub const RunArgs = struct { paths: Paths = .{}, web_dev: ?[]const u8 = null }; + pub const Command = union(enum) { - run: Paths, + run: RunArgs, check: CheckArgs, export_: ExportArgs, import_: ImportArgs, @@ -89,7 +93,7 @@ pub fn parseArgs(argv: []const []const u8) ParseError!Command { if (rest.len != 0) return error.TooManyArguments; return .help; } - if (eql(command, "run")) return .{ .run = (try parseCheckArgs(rest)).paths }; + if (eql(command, "run")) return .{ .run = try parseRunArgs(rest) }; if (eql(command, "check")) return .{ .check = try parseCheckArgs(rest) }; if (eql(command, "export")) return .{ .export_ = try parseExportArgs(rest) }; if (eql(command, "import")) return .{ .import_ = try parseImportArgs(rest) }; @@ -124,8 +128,24 @@ fn flagValue(flag: Flag, argv: []const []const u8, i: *usize) ParseError![]const return argv[i.*]; } -/// `run` and `check` take the same two flags. `run` throws away -/// `config_explicit`; bootstrap reads the path either way. +/// `run` takes `check`'s two flags plus `--web-dev`, which only a process that +/// serves has any use for; `check` deliberately rejects it. +fn parseRunArgs(argv: []const []const u8) ParseError!RunArgs { + var args: RunArgs = .{}; + var i: usize = 0; + while (i < argv.len) : (i += 1) { + const flag = splitFlag(argv[i]) orelse return error.TooManyArguments; + if (eql(flag.name, "data-dir")) { + args.paths.data_dir = try flagValue(flag, argv, &i); + } else if (eql(flag.name, "config")) { + args.paths.config = try flagValue(flag, argv, &i); + } else if (eql(flag.name, "web-dev")) { + args.web_dev = try flagValue(flag, argv, &i); + } else return error.UnknownFlag; + } + return args; +} + fn parseCheckArgs(argv: []const []const u8) ParseError!CheckArgs { var args: CheckArgs = .{}; var i: usize = 0; @@ -324,6 +344,8 @@ const usage_text = \\ --config FILE configuration file (default /etc/nxdns/config.zon) \\ --out FILE write the export to FILE instead of stdout \\ --force let import replace a database that already has content + \\ --web-dev DIR run only: serve the web interface from DIR instead of + \\ the embedded assets \\ ; @@ -375,8 +397,8 @@ pub fn runVersion(r: Runner) u8 { /// Serves DNS until SIGINT or SIGTERM. The whole of it lives in `app.zig`, /// which is where the composition root belongs; this stays the entry point so /// that `main` dispatches every command the same way. -pub fn runRun(r: Runner, paths: Paths) u8 { - return app.run(r, paths); +pub fn runRun(r: Runner, args: RunArgs) u8 { + return app.run(r, args); } pub fn runExport(r: Runner, args: ExportArgs) u8 { @@ -742,14 +764,30 @@ const testing = std.testing; test "parseArgs accepts run with no flags" { const command = try parseArgs(&.{"run"}); - try testing.expectEqualStrings("/var/lib/nxdns", command.run.data_dir); - try testing.expectEqualStrings("/etc/nxdns/config.zon", command.run.config); + try testing.expectEqualStrings("/var/lib/nxdns", command.run.paths.data_dir); + try testing.expectEqualStrings("/etc/nxdns/config.zon", command.run.paths.config); + try testing.expectEqual(@as(?[]const u8, null), command.run.web_dev); } test "parseArgs accepts run with --data-dir and --config" { const command = try parseArgs(&.{ "run", "--data-dir", "/srv/nx", "--config", "/tmp/c.zon" }); - try testing.expectEqualStrings("/srv/nx", command.run.data_dir); - try testing.expectEqualStrings("/tmp/c.zon", command.run.config); + try testing.expectEqualStrings("/srv/nx", command.run.paths.data_dir); + try testing.expectEqualStrings("/tmp/c.zon", command.run.paths.config); +} + +test "parseArgs accepts run with --web-dev in both spellings" { + const attached = try parseArgs(&.{ "run", "--web-dev=web/dist" }); + try testing.expectEqualStrings("web/dist", attached.run.web_dev.?); + + const separate = try parseArgs(&.{ "run", "--web-dev", "web/dist" }); + try testing.expectEqualStrings("web/dist", separate.run.web_dev.?); +} + +test "parseArgs rejects --web-dev without a value and outside run" { + try testing.expectError(error.MissingValue, parseArgs(&.{ "run", "--web-dev" })); + try testing.expectError(error.MissingValue, parseArgs(&.{ "run", "--web-dev=" })); + try testing.expectError(error.UnknownFlag, parseArgs(&.{ "check", "--web-dev", "web/dist" })); + try testing.expectError(error.UnknownFlag, parseArgs(&.{ "export", "--web-dev", "web/dist" })); } test "parseArgs accepts --data-dir with and without an equals sign" { diff --git a/src/config/model.zig b/src/config/model.zig index 6dbfe63..2370343 100644 --- a/src/config/model.zig +++ b/src/config/model.zig @@ -108,6 +108,10 @@ pub const Web = struct { password_hash: []const u8 = "", session_ttl_hours: u16 = 24, api_rate_limit_per_min: u32 = 300, + /// Requests from the box itself skip the API rate limit. On by default: a + /// local script or health probe is the operator's own traffic, not the + /// abuse the limiter defends against (PLAN §10). + api_localhost_exempt: bool = true, sse_max_connections_per_ip: u16 = 3, }; @@ -506,6 +510,7 @@ const expected_keys = [_][]const u8{ "upstream.connect_timeout_ms", "upstream.read_timeout_ms", "upstream.total_timeout_ms", + "web.api_localhost_exempt", "web.api_rate_limit_per_min", "web.bind", "web.enabled", @@ -571,6 +576,7 @@ test "toSettings and fromSettings round-trip a non-default config" { .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def", .session_ttl_hours = 23, .api_rate_limit_per_min = 29, + .api_localhost_exempt = false, .sse_max_connections_per_ip = 31, }, .doh_server = .{ diff --git a/src/main.zig b/src/main.zig index afde3d7..ff5d66f 100644 --- a/src/main.zig +++ b/src/main.zig @@ -45,7 +45,7 @@ pub fn main(init: std.process.Init) u8 { const command = cli.parseArgs(argv.items) catch |e| return cli.runUsageError(runner, e); return switch (command) { - .run => |paths| cli.runRun(runner, paths), + .run => |args_| cli.runRun(runner, args_), // `true`: the probe leaves the machine, which is right for an operator // running `nxdns check` and wrong for a test. .check => |args_| cli.runCheck(runner, args_, true), diff --git a/src/server/handler.zig b/src/server/handler.zig index 707f5e9..cd58bbe 100644 --- a/src/server/handler.zig +++ b/src/server/handler.zig @@ -26,6 +26,7 @@ const edns = @import("../dns/edns.zig"); const forward_client = @import("../local/forward_client.zig"); const forward_zones = @import("../local/forward_zones.zig"); const header = @import("../dns/header.zig"); +const local_tables_mod = @import("local_tables.zig"); const logger_mod = @import("../storage/logger.zig"); const manager = @import("../filter/manager.zig"); const matcher = @import("../filter/matcher.zig"); @@ -33,6 +34,7 @@ const model = @import("../config/model.zig"); const name = @import("../dns/name.zig"); const packet = @import("../dns/packet.zig"); const pause = @import("pause.zig"); +const query_sink = @import("query_sink.zig"); const question = @import("../dns/question.zig"); const rate_limiter = @import("rate_limiter.zig"); const record = @import("../dns/record.zig"); @@ -95,6 +97,12 @@ comptime { std.debug.assert(max_synthetic_len <= udp_limit_min); } +/// What a handler with no `local_tables` reads: no local record and no forward +/// zone. Static, so the null case costs a pointer rather than a branch in every +/// stage that consults them. +const empty_records: records.Records = .empty; +const empty_zones: forward_zones.Zones = .empty; + pub const Handler = struct { /// In production this is `pool.client()`. upstream: transport.Client, @@ -102,8 +110,10 @@ pub const Handler = struct { ecs_mode: model.EcsMode = .strip, forward_read_timeout: std.Io.Clock.Duration, manager: ?*manager.Manager = null, - records: *const records.Records, - zones: *const forward_zones.Zones, + /// The published local records and forward zones (milestone-8 ruling 12). + /// Null means neither table exists, which is what a handler built for one + /// upstream test wants; the API rebuilds and swaps them while queries run. + local_tables: ?*local_tables_mod.LocalTables = null, cache: ?*dns_cache.DnsCache = null, cache_mutex: std.Io.Mutex = .init, /// `cfg.cache.negative_ttl_max`. `DnsCache` keeps no copy of its config and @@ -112,7 +122,7 @@ pub const Handler = struct { negative_ttl_max: u32 = 0, limiter: ?*rate_limiter.RateLimiter = null, limiter_mutex: std.Io.Mutex = .init, - logger: ?*logger_mod.Logger = null, + sink: ?*query_sink.QuerySink = null, pause: ?*pause.Pause = null, tracker: ?*clients.Tracker = null, stats: Stats = .{}, @@ -255,6 +265,12 @@ pub const Handler = struct { const snapshot: ?*const matcher.Snapshot = if (acquired) |a| a.snapshot else null; if (snapshot == null) bump(&self.stats.unfiltered_queries); + // Ruling 12: the local tables are published the same way the snapshot + // is, so one query reads one generation of both and the API can swap + // either while queries run. + const local = if (self.local_tables) |tables| tables.acquire(io) else null; + defer if (local) |held| held.release(io); + var ctx: Context = .{ .handler = self, .io = io, @@ -271,6 +287,8 @@ pub const Handler = struct { .started = started, .now_s = started.toSeconds(), .snapshot = snapshot, + .records = if (local) |held| held.records else &empty_records, + .zones = if (local) |held| held.zones else &empty_zones, .group = if (snapshot) |s| s.groupForClient(from) else 0, .domain = matcher.normalize(q.name, &scratch.normalize), }; @@ -319,6 +337,10 @@ const Context = struct { started: std.Io.Timestamp, now_s: i64, snapshot: ?*const matcher.Snapshot, + /// Borrowed from the `LocalTables` handle this query holds, so both tables + /// belong to one generation and neither can be freed mid-query. + records: *const records.Records, + zones: *const forward_zones.Zones, group: u32, /// The queried name, normalized into `scratch.normalize`. domain: []const u8, @@ -328,8 +350,8 @@ const Context = struct { /// blocklist. fn run(ctx: *Context) Handler.Outcome { if (ctx.q.qclass != .in) return ctx.viaUpstream(.{ .filter = false, .cache = false }); - if (ctx.handler.records.hasName(ctx.domain)) return ctx.viaLocal(); - if (ctx.handler.zones.match(ctx.domain)) |zone| return ctx.viaForwardZone(zone); + if (ctx.records.hasName(ctx.domain)) return ctx.viaLocal(); + if (ctx.zones.match(ctx.domain)) |zone| return ctx.viaForwardZone(zone); const paused = if (ctx.handler.pause) |p| p.isPaused(ctx.now_s) else false; if (paused) bump(&ctx.handler.stats.paused_queries); @@ -342,7 +364,7 @@ const Context = struct { /// A local CNAME is returned as it stands (ruling 12). The client re-queries /// the target, and that query runs the whole pipeline. fn viaLocal(ctx: *Context) Handler.Outcome { - const found = ctx.handler.records.lookup(ctx.domain, ctx.q.qtype); + const found = ctx.records.lookup(ctx.domain, ctx.q.qtype); var b = packet.ResponseBuilder.init(ctx.response_buf, ctx.hdr, ctx.q) catch return ctx.servFail(); @@ -489,14 +511,14 @@ const Context = struct { } fn log(ctx: *Context, fields: LogFields) void { - const logger = ctx.handler.logger orelse return; + const sink = ctx.handler.sink orelse return; var ip_buf: [max_ip_text]u8 = undefined; var w: std.Io.Writer = .fixed(&ip_buf); ctx.from.format(&w) catch unreachable; const now = std.Io.Clock.real.now(ctx.io); - logger.log(ctx.io, logger_mod.Entry.init(.{ + sink.log(ctx.io, logger_mod.Entry.init(.{ .timestamp = ctx.now_s, .domain = ctx.domain, .client_ip = w.buffered(), @@ -861,6 +883,8 @@ fn bump(counter: *std.atomic.Value(u64)) void { // Tests // --------------------------------------------------------------------------- +const sse = @import("../web/sse.zig"); + const testing = std.testing; /// Every test needs a real `std.Io`: the handler reads the clock on every query @@ -881,8 +905,6 @@ const TestIo = struct { } }; -const empty_records: records.Records = .empty; -const empty_zones: forward_zones.Zones = .empty; const blocking: response.Options = .{ .mode = .zero, .ttl = 5 }; const forward_timeout: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(50), .clock = .awake }; const client_ip: address.NetAddress = .{ .ip4 = .{ 192, 168, 1, 50 } }; @@ -894,8 +916,6 @@ fn bare(client: transport.Client) Handler { .upstream = client, .blocking = blocking, .forward_read_timeout = forward_timeout, - .records = &empty_records, - .zones = &empty_zones, }; } @@ -1813,7 +1833,8 @@ test "a local record answers authoritatively without reaching the upstream" { var fake: FakeUpstream = .{ .reply = response_bytes }; var h = bare(fake.client()); - h.records = &table; + var tables: local_tables_mod.LocalTables = .{ .records = table }; + h.local_tables = &tables; var query_buf: [512]u8 = undefined; const query = queryFor(&query_buf, 0x1234, "nas.lan", .a, .in); @@ -1846,7 +1867,8 @@ test "a local name with no record of the queried type is authoritative NODATA" { var fake: FakeUpstream = .{ .reply = response_bytes }; var h = bare(fake.client()); - h.records = &table; + var tables: local_tables_mod.LocalTables = .{ .records = table }; + h.local_tables = &tables; var query_buf: [512]u8 = undefined; const query = queryFor(&query_buf, 0x1234, "nas.lan", .aaaa, .in); @@ -1883,7 +1905,8 @@ test "a forward zone answers from the cache and never reaches the pool" { var fake: FakeUpstream = .{ .reply = response_bytes }; var h = bare(fake.client()); - h.zones = &zones; + var tables: local_tables_mod.LocalTables = .{ .zones = zones }; + h.local_tables = &tables; h.cache = &cache; h.negative_ttl_max = 3600; h.manager = &mgr; @@ -1938,7 +1961,8 @@ test "a forward zone bypasses the blocklist and fails on its own resolver" { var fake: FakeUpstream = .{ .reply = response_bytes }; var h = bare(fake.client()); - h.zones = &zones; + var tables: local_tables_mod.LocalTables = .{ .zones = zones }; + h.local_tables = &tables; h.manager = &mgr; var query_buf: [512]u8 = undefined; @@ -2406,14 +2430,16 @@ test "every answered path logs the fields ruling 20 defines" { var queue_buf: [8]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); + var sink: query_sink.QuerySink = .init(&lg, null); var fake: FakeUpstream = .{ .reply = response_bytes }; var h = bare(fake.client()); h.manager = &mgr; - h.records = &table; + var tables: local_tables_mod.LocalTables = .{ .records = table }; + h.local_tables = &tables; h.cache = &cache; h.negative_ttl_max = 3600; - h.logger = ≶ + h.sink = &sink; var buf: [udp_limit_min]u8 = undefined; var query_buf: [512]u8 = undefined; @@ -2465,11 +2491,12 @@ test "an uncloaked block logs the cname-prefixed reason" { var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); + var sink: query_sink.QuerySink = .init(&lg, null); var fake: FakeUpstream = .{ .reply = chain }; var h = bare(fake.client()); h.manager = &mgr; - h.logger = ≶ + h.sink = &sink; var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, t.io(), query_bytes, &buf)); @@ -2483,6 +2510,41 @@ test "an uncloaked block logs the cname-prefixed reason" { try testing.expectEqualStrings("example.com", logged[0].domain()); } +test "the sink both streams and logs the query the handler answered" { + var t: TestIo = .init(); + defer t.deinit(); + const io = t.io(); + + const hub = try testing.allocator.create(sse.Hub); + defer testing.allocator.destroy(hub); + hub.init(); + + var queue_buf: [4]logger_mod.Entry = undefined; + var lg: logger_mod.Logger = .init(.{}, &queue_buf); + var sink: query_sink.QuerySink = .init(&lg, hub); + + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h = bare(fake.client()); + h.sink = &sink; + + const id = hub.subscribe(io).?; + defer hub.unsubscribe(io, id); + + var buf: [udp_limit_min]u8 = undefined; + _ = try expectReply(udp(&h, io, query_bytes, &buf)); + + const streamed = hub.next(io, id).?; + try testing.expectEqualStrings("example.com", streamed.domain()); + try testing.expectEqualStrings("192.168.1.50", streamed.clientIp()); + try testing.expectEqualStrings("pool", streamed.upstream()); + try testing.expect(hub.next(io, id) == null); + + var entries: [4]logger_mod.Entry = undefined; + const logged = drainLog(&lg, io, &entries); + try testing.expectEqual(@as(usize, 1), logged.len); + try testing.expectEqualStrings("example.com", logged[0].domain()); +} + test "a refused query is counted and never logged" { var t: TestIo = .init(); defer t.deinit(); @@ -2495,11 +2557,12 @@ test "a refused query is counted and never logged" { var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); + var sink: query_sink.QuerySink = .init(&lg, null); var fake: FakeUpstream = .{ .reply = response_bytes }; var h = bare(fake.client()); h.limiter = &limiter; - h.logger = ≶ + h.sink = &sink; var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, t.io(), query_bytes, &buf)); diff --git a/src/server/local_tables.zig b/src/server/local_tables.zig new file mode 100644 index 0000000..fb0109e --- /dev/null +++ b/src/server/local_tables.zig @@ -0,0 +1,217 @@ +//! The published local-answer tables: the compiled local records and the +//! compiled forward zones the query path reads (milestone-8 ruling 12). +//! +//! Both tables are immutable once built, so publishing a new one is a pointer +//! swap under an `std.Io.RwLock` — the blocklist manager's pattern at a much +//! smaller scale, and for the same reason: a shared lock held for the +//! microseconds of one lookup costs an uncontended atomic pair, and reclaiming +//! the old table without a lock would need epoch tracking this project has no +//! use for. +//! +//! The two tables live under one lock because one API call can change either +//! and the query path reads both in sequence. Two locks would double the cost +//! of every query to buy nothing. +//! +//! `acquire` brackets exactly one query. The handle borrows the live fields, so +//! it must not outlive its `release` — which is why `swap` may free the tables +//! it replaced as soon as it has the exclusive lock: no reader can still be +//! holding them. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const forward_zones = @import("../local/forward_zones.zig"); +const records = @import("../local/records.zig"); + +pub const LocalTables = struct { + lock: std.Io.RwLock = .init, + records: records.Records = .empty, + zones: forward_zones.Zones = .empty, + + /// No records and no zones: every name goes to the filtering path. + pub const empty: LocalTables = .{}; + + /// Reader side of the swap. The pointers are the live fields, so release + /// the handle before the query ends and do not retain them. + pub const Handle = struct { + records: *const records.Records, + zones: *const forward_zones.Zones, + tables: *LocalTables, + + pub fn release(self: Handle, io: std.Io) void { + self.tables.lock.unlockShared(io); + } + }; + + /// Uncancelable, like the manager's: the critical section is a lookup with + /// no socket and no file in it, so it always completes. + pub fn acquire(self: *LocalTables, io: std.Io) Handle { + self.lock.lockSharedUncancelable(io); + return .{ .records = &self.records, .zones = &self.zones, .tables = self }; + } + + /// Publishes `new_records` and `new_zones` and frees the tables they + /// replace. Both are installed together, so no query can see the records of + /// one generation beside the zones of another. + /// + /// The caller built both tables with `gpa` and hands ownership over here. + pub fn swap( + self: *LocalTables, + io: std.Io, + gpa: Allocator, + new_records: records.Records, + new_zones: forward_zones.Zones, + ) void { + self.lock.lockUncancelable(io); + var old_records = self.records; + var old_zones = self.zones; + self.records = new_records; + self.zones = new_zones; + // Freed while the exclusive lock is held: every reader that could hold + // the old tables released its shared lock before this one was granted. + old_records.deinit(gpa); + old_zones.deinit(gpa); + self.lock.unlock(io); + } + + /// Frees the published tables. The caller must have stopped every reader + /// first, exactly as it must before releasing any other borrowed collaborator. + pub fn deinit(self: *LocalTables, gpa: Allocator) void { + self.records.deinit(gpa); + self.zones.deinit(gpa); + self.* = .empty; + } +}; + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +const TestIo = struct { + threaded: std.Io.Threaded, + + fn init() TestIo { + return .{ .threaded = .init(testing.allocator, .{}) }; + } + + fn io(self: *TestIo) std.Io { + return self.threaded.io(); + } + + fn deinit(self: *TestIo) void { + self.threaded.deinit(); + } +}; + +fn buildRecords(name: []const u8, value: []const u8) !records.Records { + return records.Records.build(testing.allocator, &.{ + .{ .name = name, .rtype = .a, .value = value, .ttl = 60 }, + }); +} + +fn buildZones(zone: []const u8) !forward_zones.Zones { + return forward_zones.Zones.build(testing.allocator, &.{ + .{ .zone = zone, .resolver = "udp://10.0.0.1:53" }, + }); +} + +test "an empty holder answers nothing and frees nothing" { + var t: TestIo = .init(); + defer t.deinit(); + + var tables: LocalTables = .empty; + defer tables.deinit(testing.allocator); + + const handle = tables.acquire(t.io()); + defer handle.release(t.io()); + + try testing.expect(!handle.records.hasName("nas.lan")); + try testing.expectEqual(@as(?*const forward_zones.Zone, null), handle.zones.match("nas.lan")); +} + +test "a swap publishes both tables together" { + var t: TestIo = .init(); + defer t.deinit(); + + var tables: LocalTables = .empty; + defer tables.deinit(testing.allocator); + + tables.swap(t.io(), testing.allocator, try buildRecords("nas.lan", "192.168.1.10"), try buildZones("lan")); + + const handle = tables.acquire(t.io()); + defer handle.release(t.io()); + try testing.expect(handle.records.hasName("nas.lan")); + try testing.expect(handle.zones.match("nas.lan") != null); +} + +test "a second swap frees the tables it replaces" { + var t: TestIo = .init(); + defer t.deinit(); + + var tables: LocalTables = .empty; + defer tables.deinit(testing.allocator); + + tables.swap(t.io(), testing.allocator, try buildRecords("old.lan", "192.168.1.10"), try buildZones("old")); + tables.swap(t.io(), testing.allocator, try buildRecords("new.lan", "192.168.1.11"), try buildZones("new")); + + const handle = tables.acquire(t.io()); + defer handle.release(t.io()); + try testing.expect(!handle.records.hasName("old.lan")); + try testing.expect(handle.records.hasName("new.lan")); + try testing.expect(handle.zones.match("host.old") == null); + try testing.expect(handle.zones.match("host.new") != null); +} + +test "a handle keeps reading the generation it acquired" { + var t: TestIo = .init(); + defer t.deinit(); + + var tables: LocalTables = .empty; + defer tables.deinit(testing.allocator); + + tables.swap(t.io(), testing.allocator, try buildRecords("first.lan", "192.168.1.10"), .empty); + + const handle = tables.acquire(t.io()); + try testing.expect(handle.records.hasName("first.lan")); + // Reading twice under one handle must give one answer, which is the whole + // point of bracketing a query rather than each lookup. + try testing.expect(handle.records.hasName("first.lan")); + handle.release(t.io()); + + tables.swap(t.io(), testing.allocator, try buildRecords("second.lan", "192.168.1.11"), .empty); + + const after = tables.acquire(t.io()); + defer after.release(t.io()); + try testing.expect(after.records.hasName("second.lan")); +} + +test "a swap waits for a live reader and the reader sees the new tables next time" { + var t: TestIo = .init(); + defer t.deinit(); + const io = t.io(); + + var tables: LocalTables = .empty; + defer tables.deinit(testing.allocator); + + const Swapper = struct { + fn run(target: *LocalTables, inner: std.Io, gpa: Allocator) void { + const built = records.Records.build(gpa, &.{ + .{ .name = "swapped.lan", .rtype = .a, .value = "192.168.1.12", .ttl = 60 }, + }) catch return; + target.swap(inner, gpa, built, .empty); + } + }; + + const handle = tables.acquire(io); + var group: std.Io.Group = .init; + try group.concurrent(io, Swapper.run, .{ &tables, io, testing.allocator }); + try testing.expect(!handle.records.hasName("swapped.lan")); + handle.release(io); + try group.await(io); + + const after = tables.acquire(io); + defer after.release(io); + try testing.expect(after.records.hasName("swapped.lan")); +} diff --git a/src/server/phase7_integration_test.zig b/src/server/phase7_integration_test.zig index b4fe2a8..2dc2409 100644 --- a/src/server/phase7_integration_test.zig +++ b/src/server/phase7_integration_test.zig @@ -31,6 +31,7 @@ const dns_cache = @import("../cache/dns_cache.zig"); const forward_zones = @import("../local/forward_zones.zig"); const handler = @import("handler.zig"); const header = @import("../dns/header.zig"); +const local_tables = @import("local_tables.zig"); const logger_mod = @import("../storage/logger.zig"); const manager = @import("../filter/manager.zig"); const matcher = @import("../filter/matcher.zig"); @@ -39,6 +40,7 @@ const model = @import("../config/model.zig"); const name = @import("../dns/name.zig"); const packet = @import("../dns/packet.zig"); const pause = @import("pause.zig"); +const query_sink = @import("query_sink.zig"); const question = @import("../dns/question.zig"); const rate_limiter = @import("rate_limiter.zig"); const record = @import("../dns/record.zig"); @@ -59,9 +61,6 @@ const testing = std.testing; /// enough that a broken server fails the run instead of hanging it. const budget: std.Io.Timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } }; -const empty_records: records.Records = .empty; -const empty_zones: forward_zones.Zones = .empty; - /// A five-second TTL makes the blocking answer's TTL unmistakable next to the /// upstream's 300. const blocking: response.Options = .{ .mode = .zero, .ttl = 5 }; @@ -84,8 +83,6 @@ fn baseHandler(client: transport.Client) handler.Handler { .upstream = client, .blocking = blocking, .forward_read_timeout = forward_timeout, - .records = &empty_records, - .zones = &empty_zones, }; } @@ -316,11 +313,12 @@ test "S7 case 1: a blocked domain is answered with the zero address and logged" var queue_buf: [log_queue_len]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); + var sink: query_sink.QuerySink = .init(&lg, null); var fake: FakeUpstream = .{ .reply = .a }; var h = baseHandler(fake.client()); h.manager = &mgr; - h.logger = ≶ + h.sink = &sink; var loop = try Loop.bind(gpa, io, &h); defer loop.stop(gpa, io); @@ -409,7 +407,8 @@ test "S7 case 3: a local record answers authoritatively without an upstream" { var fake: FakeUpstream = .{ .reply = .a }; var h = baseHandler(fake.client()); - h.records = &table; + var tables: local_tables.LocalTables = .{ .records = table }; + h.local_tables = &tables; var loop = try Loop.bind(gpa, io, &h); defer loop.stop(gpa, io); @@ -477,7 +476,8 @@ test "S7 case 4: a forward zone reaches its resolver, bypasses the blocklist and var fake: FakeUpstream = .{ .reply = .a }; var h = baseHandler(fake.client()); h.manager = &mgr; - h.zones = &zones; + var tables: local_tables.LocalTables = .{ .zones = zones }; + h.local_tables = &tables; h.cache = &cache; h.negative_ttl_max = 3600; @@ -528,12 +528,13 @@ test "S7 case 5: a cached answer comes back with a fresh id, an aged ttl and a l var queue_buf: [log_queue_len]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); + var sink: query_sink.QuerySink = .init(&lg, null); var fake: FakeUpstream = .{ .reply = .a }; var h = baseHandler(fake.client()); h.cache = &cache; h.negative_ttl_max = 3600; - h.logger = ≶ + h.sink = &sink; var loop = try Loop.bind(gpa, io, &h); defer loop.stop(gpa, io); @@ -616,11 +617,12 @@ test "S7 case 6: a cname into a blocked target blocks the original question" { var queue_buf: [log_queue_len]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); + var sink: query_sink.QuerySink = .init(&lg, null); var fake: FakeUpstream = .{ .reply = .{ .cname = "tracker.example.org" } }; var h = baseHandler(fake.client()); h.manager = &mgr; - h.logger = ≶ + h.sink = &sink; var loop = try Loop.bind(gpa, io, &h); defer loop.stop(gpa, io); @@ -724,11 +726,12 @@ test "S7 case 8: the third query inside the window is refused" { var queue_buf: [log_queue_len]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); + var sink: query_sink.QuerySink = .init(&lg, null); var fake: FakeUpstream = .{ .reply = .a }; var h = baseHandler(fake.client()); h.limiter = &limiter; - h.logger = ≶ + h.sink = &sink; var loop = try Loop.bind(gpa, io, &h); defer loop.stop(gpa, io); @@ -961,10 +964,10 @@ test "S7 case 11: the app boots, serves a query and exits zero on shutdown" { shutdown.reset(); defer shutdown.reset(); - var future = try test_io.concurrent(app.run, .{ runner, cli.Paths{ + var future = try test_io.concurrent(app.run, .{ runner, cli.RunArgs{ .paths = .{ .data_dir = root, .config = config_path, - } }); + } } }); const client_address: net.IpAddress = try .parse("127.0.0.1", 0); const client = try client_address.bind(test_io, .{ .mode = .dgram }); diff --git a/src/server/query_sink.zig b/src/server/query_sink.zig new file mode 100644 index 0000000..8aadf85 --- /dev/null +++ b/src/server/query_sink.zig @@ -0,0 +1,148 @@ +//! Where the query path hands off a finished query (PLAN §11.4). +//! +//! Milestone 6 gave the handler a `Logger`; milestone 8 gives it a second +//! consumer, the SSE hub. `QuerySink` is that fanout, and it exists so the +//! handler still makes one call and the privacy transforms still run exactly +//! once, before either consumer sees the entry. +//! +//! Order is load-bearing: PLAN:455 puts fanout ahead of persistence, so a live +//! stream shows a query while the row is still queued for the database. +//! `Hub.publish` copies and returns, so publishing first costs the query path +//! nothing it would not have paid anyway. + +const std = @import("std"); + +const logger = @import("../storage/logger.zig"); +const sse = @import("../web/sse.zig"); + +pub const QuerySink = struct { + logger: *logger.Logger, + /// Null when `web.enabled` is false: nothing subscribes, so nothing needs + /// a hub, and the DNS path pays one null check. + hub: ?*sse.Hub, + + pub fn init(query_logger: *logger.Logger, hub: ?*sse.Hub) QuerySink { + return .{ .logger = query_logger, .hub = hub }; + } + + /// Transforms once, publishes, then enqueues. Never blocks the query path + /// and never fails: both consumers drop rather than wait. + pub fn log(self: *QuerySink, io: std.Io, entry: logger.Entry) void { + const transformed = self.logger.transformed(entry); + if (self.hub) |hub| hub.publish(io, transformed); + self.logger.logTransformed(io, transformed); + } +}; + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +fn sampleEntry(timestamp: i64, domain: []const u8) logger.Entry { + return .init(.{ + .timestamp = timestamp, + .domain = domain, + .client_ip = "192.0.2.10", + .qtype = 1, + }); +} + +test "the sink publishes and logs the same entry" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const hub = try testing.allocator.create(sse.Hub); + defer testing.allocator.destroy(hub); + hub.init(); + + var queue_buf: [4]logger.Entry = undefined; + var query_logger: logger.Logger = .init(.{}, &queue_buf); + var sink: QuerySink = .init(&query_logger, hub); + + const id = hub.subscribe(io).?; + defer hub.unsubscribe(io, id); + + sink.log(io, sampleEntry(11, "example.com")); + + const streamed = hub.next(io, id).?; + try testing.expectEqualStrings("example.com", streamed.domain()); + try testing.expectEqual(@as(i64, 11), streamed.timestamp); + + const queued = try query_logger.queue.getOne(io); + try testing.expectEqualStrings("example.com", queued.domain()); + try testing.expectEqual(@as(i64, 11), queued.timestamp); +} + +test "fanout does not depend on the entry reaching the queue" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const hub = try testing.allocator.create(sse.Hub); + defer testing.allocator.destroy(hub); + hub.init(); + + var queue_buf: [4]logger.Entry = undefined; + var query_logger: logger.Logger = .init(.{}, &queue_buf); + var sink: QuerySink = .init(&query_logger, hub); + + const id = hub.subscribe(io).?; + defer hub.unsubscribe(io, id); + + // A closed queue drops what it is handed. The stream still carries the + // query, which is only true because the publish happens first. + query_logger.shutdown(io); + sink.log(io, sampleEntry(3, "ordered.example")); + + try testing.expectEqualStrings("ordered.example", hub.next(io, id).?.domain()); + try testing.expectEqual(@as(u64, 1), query_logger.queries_dropped.load(.monotonic)); +} + +test "the privacy transforms run once, before both consumers" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const hub = try testing.allocator.create(sse.Hub); + defer testing.allocator.destroy(hub); + hub.init(); + + var queue_buf: [4]logger.Entry = undefined; + var query_logger: logger.Logger = .init( + .{ .hide_domains = true, .hide_client_ips = true }, + &queue_buf, + ); + var sink: QuerySink = .init(&query_logger, hub); + + const id = hub.subscribe(io).?; + defer hub.unsubscribe(io, id); + + sink.log(io, sampleEntry(4, "tracker.example")); + + const streamed = hub.next(io, id).?; + try testing.expectEqualStrings(logger.hidden_marker, streamed.domain()); + try testing.expectEqualStrings(logger.hidden_marker, streamed.clientIp()); + + const queued = try query_logger.queue.getOne(io); + try testing.expectEqualStrings(logger.hidden_marker, queued.domain()); + try testing.expectEqualStrings(logger.hidden_marker, queued.clientIp()); +} + +test "a sink without a hub still logs" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var queue_buf: [4]logger.Entry = undefined; + var query_logger: logger.Logger = .init(.{}, &queue_buf); + var sink: QuerySink = .init(&query_logger, null); + + sink.log(io, sampleEntry(5, "nohub.example")); + + const queued = try query_logger.queue.getOne(io); + try testing.expectEqualStrings("nohub.example", queued.domain()); + try testing.expectEqual(@as(u64, 0), query_logger.queries_dropped.load(.monotonic)); +} diff --git a/src/server/resolver_integration_test.zig b/src/server/resolver_integration_test.zig index 22a2908..4274344 100644 --- a/src/server/resolver_integration_test.zig +++ b/src/server/resolver_integration_test.zig @@ -20,8 +20,6 @@ const tcp_server = @import("tcp_server.zig"); const udp_server = @import("udp_server.zig"); const model = @import("../config/model.zig"); const response = @import("../filter/response.zig"); -const forward_zones = @import("../local/forward_zones.zig"); -const records = @import("../local/records.zig"); const packet = @import("../dns/packet.zig"); const record = @import("../dns/record.zig"); const types = @import("../dns/types.zig"); @@ -31,8 +29,6 @@ const transport = @import("../upstream/transport.zig"); const testing = std.testing; -const empty_records: records.Records = .empty; -const empty_zones: forward_zones.Zones = .empty; const blocking_defaults: model.Blocking = .{}; const blocking: response.Options = .{ .mode = blocking_defaults.response, @@ -51,8 +47,6 @@ fn bareHandler(client: transport.Client) handler.Handler { .upstream = client, .blocking = blocking, .forward_read_timeout = forward_timeout, - .records = &empty_records, - .zones = &empty_zones, }; } diff --git a/src/server/tcp_server_integration_test.zig b/src/server/tcp_server_integration_test.zig index 63d1369..2612442 100644 --- a/src/server/tcp_server_integration_test.zig +++ b/src/server/tcp_server_integration_test.zig @@ -17,8 +17,6 @@ const handler = @import("handler.zig"); const tcp_server = @import("tcp_server.zig"); const model = @import("../config/model.zig"); const response = @import("../filter/response.zig"); -const forward_zones = @import("../local/forward_zones.zig"); -const records = @import("../local/records.zig"); const header = @import("../dns/header.zig"); const packet = @import("../dns/packet.zig"); const types = @import("../dns/types.zig"); @@ -26,8 +24,6 @@ const transport = @import("../upstream/transport.zig"); const testing = std.testing; -const empty_records: records.Records = .empty; -const empty_zones: forward_zones.Zones = .empty; const blocking_defaults: model.Blocking = .{}; const blocking: response.Options = .{ .mode = blocking_defaults.response, @@ -46,8 +42,6 @@ fn bareHandler(client: transport.Client) handler.Handler { .upstream = client, .blocking = blocking, .forward_read_timeout = forward_timeout, - .records = &empty_records, - .zones = &empty_zones, }; } diff --git a/src/server/udp_server_integration_test.zig b/src/server/udp_server_integration_test.zig index d5ab52d..415dfb1 100644 --- a/src/server/udp_server_integration_test.zig +++ b/src/server/udp_server_integration_test.zig @@ -16,8 +16,6 @@ const handler = @import("handler.zig"); const udp_server = @import("udp_server.zig"); const model = @import("../config/model.zig"); const response = @import("../filter/response.zig"); -const forward_zones = @import("../local/forward_zones.zig"); -const records = @import("../local/records.zig"); const header = @import("../dns/header.zig"); const packet = @import("../dns/packet.zig"); const types = @import("../dns/types.zig"); @@ -25,8 +23,6 @@ const transport = @import("../upstream/transport.zig"); const testing = std.testing; -const empty_records: records.Records = .empty; -const empty_zones: forward_zones.Zones = .empty; const blocking_defaults: model.Blocking = .{}; const blocking: response.Options = .{ .mode = blocking_defaults.response, @@ -45,8 +41,6 @@ fn bareHandler(client: transport.Client) handler.Handler { .upstream = client, .blocking = blocking, .forward_read_timeout = forward_timeout, - .records = &empty_records, - .zones = &empty_zones, }; } diff --git a/src/storage/logger.zig b/src/storage/logger.zig index d0e01c5..234de8e 100644 --- a/src/storage/logger.zig +++ b/src/storage/logger.zig @@ -7,9 +7,11 @@ //! the moment the query finishes. That is the whole reason this file has fixed //! buffers instead of slices. //! -//! The privacy transforms of §11.4 run inside `log`, before the entry is -//! enqueued, so nothing downstream — the database now, Phase 8's event stream -//! later — can observe a value the operator asked to hide. +//! The privacy transforms of §11.4 run in `transformed`, before the entry is +//! enqueued, so nothing downstream — the database or the event stream — can +//! observe a value the operator asked to hide. `log` is the two halves in +//! order; `QuerySink` calls them separately so both of its consumers see the +//! one transformed entry. //! //! Log rows are expendable. A full queue drops the oldest unflushed entry, a //! failed batch is dropped whole, and a disk that crossed the critical @@ -191,10 +193,23 @@ pub const Logger = struct { /// Applies the privacy transforms and enqueues without ever blocking the /// query path. A full queue loses its oldest unflushed entry (§11.4). pub fn log(self: *Logger, io: std.Io, entry: Entry) void { - var transformed = entry; - if (self.cfg.hide_domains) transformed.setDomain(hidden_marker); - if (self.cfg.hide_client_ips) transformed.setClientIp(hidden_marker); - self.enqueue(io, transformed); + self.logTransformed(io, self.transformed(entry)); + } + + /// The §11.4 privacy transforms, on their own. `QuerySink` runs them once + /// and hands the result to every consumer, so nothing downstream — the + /// database or the event stream — can observe a value the operator asked + /// to hide. + pub fn transformed(self: *const Logger, entry: Entry) Entry { + var out = entry; + if (self.cfg.hide_domains) out.setDomain(hidden_marker); + if (self.cfg.hide_client_ips) out.setClientIp(hidden_marker); + return out; + } + + /// `log` without the transforms, for a caller that already applied them. + pub fn logTransformed(self: *Logger, io: std.Io, entry: Entry) void { + self.enqueue(io, entry); } /// Retries until the put succeeds, and each failed attempt drops exactly @@ -556,6 +571,36 @@ test "log hides only the field its switch names" { try testing.expectEqualStrings("192.0.2.10", untouched.clientIp()); } +test "the split halves reproduce log byte for byte" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const configs = [_]model.Logging{ + .{}, + .{ .hide_domains = true }, + .{ .hide_client_ips = true }, + .{ .hide_domains = true, .hide_client_ips = true }, + }; + + for (configs) |cfg| { + var buf: [4]Entry = undefined; + var logger: Logger = .init(cfg, &buf); + const source = sampleEntry(100, "tracker.example"); + + logger.log(io, source); + logger.logTransformed(io, logger.transformed(source)); + + const from_log = try logger.queue.getOne(io); + const from_halves = try logger.queue.getOne(io); + try testing.expectEqualSlices( + u8, + std.mem.asBytes(&from_log), + std.mem.asBytes(&from_halves), + ); + } +} + test "a full queue drops the oldest entry and counts it" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); diff --git a/src/storage/phase6_integration_test.zig b/src/storage/phase6_integration_test.zig index c216627..93b9918 100644 --- a/src/storage/phase6_integration_test.zig +++ b/src/storage/phase6_integration_test.zig @@ -362,10 +362,10 @@ test "S8 case 5: a retention pass prunes the old rows and truncates the write-ah var pass: retention.Retention = .init(.{ .retention_days = 30 }); pass.runOnce(io, log_db.database()); - try testing.expectEqual(@as(u64, 1), pass.stats.passes); - try testing.expectEqual(@as(u64, 2), pass.stats.rows_pruned); - try testing.expectEqual(@as(u64, 1), pass.stats.checkpoints); - try testing.expectEqual(@as(u64, 0), pass.stats.vacuums); + try testing.expectEqual(@as(u64, 1), pass.snapshotStats().passes); + try testing.expectEqual(@as(u64, 2), pass.snapshotStats().rows_pruned); + try testing.expectEqual(@as(u64, 1), pass.snapshotStats().checkpoints); + try testing.expectEqual(@as(u64, 0), pass.snapshotStats().vacuums); try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(log_db.database())); // Both names stay: the dimension table is not collected. try testing.expectEqual(@as(i64, 2), try queries_repo.countDomains(log_db.database())); diff --git a/src/storage/repositories/clients_repo.zig b/src/storage/repositories/clients_repo.zig index 6903a9b..d32ebb2 100644 --- a/src/storage/repositories/clients_repo.zig +++ b/src/storage/repositories/clients_repo.zig @@ -5,8 +5,10 @@ //! not appear in an export. `countClients` counts **all** rows, because S5's //! "has this database ever been configured" predicate needs the true count. //! -//! Only list / insert / deleteAll / count, plus the two runtime calls -//! `upsertSeen` and `pruneStale` that the Phase 7 client tracker owns. +//! The import path is list / insert / deleteAll / count, plus the two runtime +//! calls `upsertSeen` and `pruneStale` that the Phase 7 client tracker owns. +//! Phase 8's REST surface is the third section: it speaks row ids and shows +//! every client, materialised ones included. const std = @import("std"); const Allocator = std.mem.Allocator; @@ -15,6 +17,7 @@ const db = @import("../db.zig"); const migrations = @import("../migrations.zig"); const model = @import("../../config/model.zig"); const context = @import("context.zig"); +const crud = @import("crud.zig"); const IdMap = context.IdMap; const InsertContext = context.InsertContext; @@ -193,6 +196,255 @@ pub fn countClientPrefixes(database: *db.Db) db.Error!i64 { return database.queryInt("SELECT count(*) FROM client_prefixes"); } +// --------------------------------------------------------------------------- +// REST surface (milestone 8) +// --------------------------------------------------------------------------- +// +// `/api/clients` shows every row — a device the server materialised from live +// traffic is exactly what the operator wants to name — so these reads carry no +// `hand_edited` filter and report the flag instead. The write shapes take +// `group_id`, not a group name: the REST layer identifies every resource by row +// id, and a `group_id` no group holds must surface as the foreign-key violation +// it is. + +pub const ClientRow = struct { + id: i64, + ip: []const u8, + /// `clients.name` is nullable; a NULL reads as `""`, as it does on the + /// import path. + name: []const u8, + group_id: i64, + group: []const u8, + hand_edited: bool, + first_seen: i64, + last_seen: i64, +}; + +/// What creating a client by hand needs. `first_seen` and `last_seen` are the +/// caller's clock, so this shape does not carry them. +pub const ClientInput = struct { + ip: []const u8, + name: []const u8 = "", + group_id: i64, +}; + +/// What editing a client may change (ruling 9). `ip` is absent on purpose: it is +/// the identity live traffic matches a row by, and rewriting it would collide +/// with the row the tracker materialises for the device that still holds it. +pub const ClientEdit = struct { + name: []const u8 = "", + group_id: i64, +}; + +const list_client_rows_sql = + \\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen + \\ FROM clients c + \\ JOIN groups g ON g.id = c.group_id + \\ ORDER BY c.ip +; + +const get_client_sql = + \\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen + \\ FROM clients c + \\ JOIN groups g ON g.id = c.group_id + \\ WHERE c.id = ?1 +; + +/// Every client, materialised ones included. Every string is a heap copy owned +/// by `gpa`. +pub fn listClientRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientRow) { + var stmt = try database.prepare(list_client_rows_sql); + defer stmt.deinit(); + + var out: std.ArrayList(ClientRow) = .empty; + errdefer out.deinit(gpa); + errdefer freeClientRows(gpa, out.items); + + while (try stmt.step()) { + const row = try readClientRow(&stmt, gpa); + errdefer freeClientRow(gpa, row); + try out.append(gpa, row); + } + return out; +} + +pub fn freeClientRow(gpa: Allocator, row: ClientRow) void { + gpa.free(row.ip); + gpa.free(row.name); + gpa.free(row.group); +} + +pub fn freeClientRows(gpa: Allocator, items: []const ClientRow) void { + for (items) |item| freeClientRow(gpa, item); +} + +pub fn getClient(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ClientRow { + var stmt = try database.prepare(get_client_sql); + defer stmt.deinit(); + try stmt.bindInt(1, id); + if (!try stmt.step()) return null; + return try readClientRow(&stmt, gpa); +} + +fn readClientRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ClientRow { + const ip = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(ip); + const name = try stmt.columnTextAlloc(gpa, 2); + errdefer gpa.free(name); + const group = try stmt.columnTextAlloc(gpa, 4); + errdefer gpa.free(group); + return .{ + .id = stmt.columnInt(0), + .ip = ip, + .name = name, + .group_id = stmt.columnInt(3), + .group = group, + .hand_edited = stmt.columnBool(5), + .first_seen = stmt.columnInt(6), + .last_seen = stmt.columnInt(7), + }; +} + +const insert_client_row_sql = + \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen) + \\VALUES (?1, ?2, ?3, 1, ?4, ?4) +; + +/// Creates a client the operator typed, so `hand_edited` is 1 — the difference +/// from `upsertSeen`, which materialises what the DNS path saw and never claims +/// a row is configuration. +/// +/// `now_s` is unix epoch seconds, from `std.Io.Clock.real`; it seeds both +/// timestamps, exactly as `insertClient` does on the import path. +/// +/// `error.Constraint`: `clients.ip` is UNIQUE, or `group_id` names no group. +pub fn insertClientRow(database: *db.Db, item: ClientInput, now_s: i64) db.Error!i64 { + var stmt = try database.prepare(insert_client_row_sql); + defer stmt.deinit(); + try stmt.bindText(1, item.ip); + try stmt.bindText(2, item.name); + try stmt.bindInt(3, item.group_id); + try stmt.bindInt(4, now_s); + try stmt.exec(); + return database.lastInsertRowid(); +} + +/// An edit is what makes a client configuration, so this sets `hand_edited` to +/// 1 on every call (ruling 9) and `pruneStale` stops considering the row. +/// `first_seen` and `last_seen` stay the tracker's. +/// +/// `error.NotFound`: no client holds `id`. `error.Constraint`: `group_id` names +/// no group. +pub fn updateClient(database: *db.Db, id: i64, item: ClientEdit) db.Error!void { + var stmt = try database.prepare( + "UPDATE clients SET name = ?2, group_id = ?3, hand_edited = 1 WHERE id = ?1", + ); + defer stmt.deinit(); + try stmt.bindInt(1, id); + try stmt.bindText(2, item.name); + try stmt.bindInt(3, item.group_id); + return crud.execStrict(database, &stmt); +} + +/// `error.NotFound`: no client holds `id`. Nothing references `clients`, so a +/// delete cannot violate a constraint — and a device that keeps querying +/// re-materialises through `upsertSeen`. +pub fn deleteClient(database: *db.Db, id: i64) db.Error!void { + var stmt = try database.prepare("DELETE FROM clients WHERE id = ?1"); + defer stmt.deinit(); + try stmt.bindInt(1, id); + return crud.execStrict(database, &stmt); +} + +pub const ClientPrefixRow = struct { + id: i64, + prefix: []const u8, + group_id: i64, + group: []const u8, + priority: i32, +}; + +pub const ClientPrefixInput = struct { + prefix: []const u8, + group_id: i64, + priority: i32 = 100, +}; + +const list_client_prefix_rows_sql = + \\SELECT p.id, p.prefix, p.group_id, g.name, p.priority FROM client_prefixes p + \\ JOIN groups g ON g.id = p.group_id + \\ ORDER BY p.prefix +; + +/// Same order as `listClientPrefixes`; every string is a heap copy owned by +/// `gpa`. +pub fn listClientPrefixRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientPrefixRow) { + var stmt = try database.prepare(list_client_prefix_rows_sql); + defer stmt.deinit(); + + var out: std.ArrayList(ClientPrefixRow) = .empty; + errdefer out.deinit(gpa); + errdefer freeClientPrefixRows(gpa, out.items); + + while (try stmt.step()) { + const prefix = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(prefix); + const group = try stmt.columnTextAlloc(gpa, 3); + errdefer gpa.free(group); + // The column is a 64-bit integer; the row field is `i32`. A value + // outside that range means something other than nxdns wrote the row. + const priority = std.math.cast(i32, stmt.columnInt(4)) orelse return error.Mismatch; + try out.append(gpa, .{ + .id = stmt.columnInt(0), + .prefix = prefix, + .group_id = stmt.columnInt(2), + .group = group, + .priority = priority, + }); + } + return out; +} + +pub fn freeClientPrefixRow(gpa: Allocator, row: ClientPrefixRow) void { + gpa.free(row.prefix); + gpa.free(row.group); +} + +pub fn freeClientPrefixRows(gpa: Allocator, items: []const ClientPrefixRow) void { + for (items) |item| freeClientPrefixRow(gpa, item); +} + +/// Replaces the whole prefix table inside a transaction (ruling 9 makes +/// `/api/client-prefixes` one atomic list resource). Row ids do not survive the +/// call: every row is written fresh. +/// +/// `error.Constraint`: `client_prefixes.prefix` is UNIQUE, so a prefix repeated +/// in `items` is rejected rather than collapsed — two rows for one prefix with +/// different groups or priorities is a contradiction, not a set. Also fires when +/// a `group_id` names no group. Either way the old table survives untouched. +pub fn replaceClientPrefixes(database: *db.Db, items: []const ClientPrefixInput) db.Error!void { + var tx = try db.Tx.begin(database); + errdefer tx.rollback(); + + try deleteAllClientPrefixes(database); + + var stmt = try database.prepare( + "INSERT INTO client_prefixes (prefix, group_id, priority) VALUES (?1, ?2, ?3)", + ); + defer stmt.deinit(); + for (items) |item| { + // `reset` clears the bindings too, so all three are bound again on + // every pass. + try stmt.reset(); + try stmt.bindText(1, item.prefix); + try stmt.bindInt(2, item.group_id); + try stmt.bindInt(3, item.priority); + try stmt.exec(); + } + + try tx.commit(); +} + // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- @@ -498,3 +750,187 @@ test "listClientPrefixes is leak-safe under allocation failure" { try ids.put(testing.allocator, "kids", 2); try testing.checkAllAllocationFailures(testing.allocator, listClientPrefixesUnderFailure, .{&ids}); } + +// --- REST surface ---------------------------------------------------------- + +test "a client round-trips through insert, get, list, update and delete" { + var database = try openMigrated(); + defer database.close(); + var ids = try seedGroups(&database); + defer ids.deinit(testing.allocator); + + const id = try insertClientRow(&database, .{ + .ip = "192.168.1.7", + .name = "printer", + .group_id = 2, + }, 1700000000); + + const fetched = (try getClient(&database, testing.allocator, id)).?; + defer freeClientRow(testing.allocator, fetched); + try testing.expectEqual(id, fetched.id); + try testing.expectEqualStrings("192.168.1.7", fetched.ip); + try testing.expectEqualStrings("printer", fetched.name); + try testing.expectEqual(@as(i64, 2), fetched.group_id); + try testing.expectEqualStrings("kids", fetched.group); + try testing.expect(fetched.hand_edited); + try testing.expectEqual(@as(i64, 1700000000), fetched.first_seen); + try testing.expectEqual(@as(i64, 1700000000), fetched.last_seen); + + try updateClient(&database, id, .{ .name = "label printer", .group_id = 1 }); + const updated = (try getClient(&database, testing.allocator, id)).?; + defer freeClientRow(testing.allocator, updated); + try testing.expectEqualStrings("label printer", updated.name); + try testing.expectEqualStrings("default", updated.group); + try testing.expectEqualStrings("192.168.1.7", updated.ip); + // The tracker's timestamps are not the editor's to move. + try testing.expectEqual(@as(i64, 1700000000), updated.first_seen); + + try deleteClient(&database, id); + try testing.expectEqual(@as(?ClientRow, null), try getClient(&database, testing.allocator, id)); + try testing.expectEqual(@as(i64, 0), try countClients(&database)); +} + +test "listClientRows shows materialised clients with hand_edited false" { + var database = try openMigrated(); + defer database.close(); + var ids = try seedGroups(&database); + defer ids.deinit(testing.allocator); + + _ = try insertClientRow(&database, .{ .ip = "192.168.1.10", .name = "desk", .group_id = 1 }, 1700000000); + try upsertSeen(&database, "192.168.1.99", 1700000500); + + var rows = try listClientRows(&database, testing.allocator); + defer rows.deinit(testing.allocator); + defer freeClientRows(testing.allocator, rows.items); + + try testing.expectEqual(@as(usize, 2), rows.items.len); + try testing.expectEqualStrings("192.168.1.10", rows.items[0].ip); + try testing.expect(rows.items[0].hand_edited); + try testing.expectEqualStrings("192.168.1.99", rows.items[1].ip); + try testing.expect(!rows.items[1].hand_edited); + // A materialised row carries no name; NULL reads as the empty string. + try testing.expectEqualStrings("", rows.items[1].name); + try testing.expectEqualStrings("default", rows.items[1].group); +} + +test "an edited client stops being a candidate for pruneStale" { + var database = try openMigrated(); + defer database.close(); + + try upsertSeen(&database, "192.168.1.99", 1700000000); + const id = (try database.queryInt("SELECT id FROM clients WHERE ip = '192.168.1.99'")); + try updateClient(&database, id, .{ .name = "tv", .group_id = 1 }); + + try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1800000000)); + const row = (try getClient(&database, testing.allocator, id)).?; + defer freeClientRow(testing.allocator, row); + try testing.expect(row.hand_edited); +} + +test "client update and delete report NotFound for an id no row holds" { + var database = try openMigrated(); + defer database.close(); + + try testing.expectError(error.NotFound, updateClient(&database, 404, .{ .group_id = 1 })); + try testing.expectError(error.NotFound, deleteClient(&database, 404)); + try testing.expectEqual(@as(?ClientRow, null), try getClient(&database, testing.allocator, 404)); +} + +test "a duplicate ip and an unknown group both surface as error.Constraint" { + var database = try openMigrated(); + defer database.close(); + + const id = try insertClientRow(&database, .{ .ip = "192.168.1.7", .group_id = 1 }, 1); + try testing.expectError( + error.Constraint, + insertClientRow(&database, .{ .ip = "192.168.1.7", .group_id = 1 }, 1), + ); + try testing.expectError( + error.Constraint, + insertClientRow(&database, .{ .ip = "192.168.1.8", .group_id = 404 }, 1), + ); + try testing.expectError(error.Constraint, updateClient(&database, id, .{ .group_id = 404 })); + try testing.expectEqual(@as(i64, 1), try countClients(&database)); +} + +test "client prefixes replace as one atomic list" { + var database = try openMigrated(); + defer database.close(); + var ids = try seedGroups(&database); + defer ids.deinit(testing.allocator); + + try replaceClientPrefixes(&database, &.{ + .{ .prefix = "192.168.2.0/24", .group_id = 2, .priority = 10 }, + .{ .prefix = "192.168.1.0/24", .group_id = 1, .priority = 50 }, + }); + + var rows = try listClientPrefixRows(&database, testing.allocator); + defer rows.deinit(testing.allocator); + defer freeClientPrefixRows(testing.allocator, rows.items); + try testing.expectEqual(@as(usize, 2), rows.items.len); + try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix); + try testing.expectEqual(@as(i64, 1), rows.items[0].group_id); + try testing.expectEqualStrings("default", rows.items[0].group); + try testing.expectEqual(@as(i32, 50), rows.items[0].priority); + try testing.expectEqualStrings("192.168.2.0/24", rows.items[1].prefix); + try testing.expectEqualStrings("kids", rows.items[1].group); + try testing.expectEqual(@as(i32, 10), rows.items[1].priority); + try testing.expect(rows.items[0].id != rows.items[1].id); + + // The replacement is total, and the empty list clears the table. + try replaceClientPrefixes(&database, &.{.{ .prefix = "fd00::/48", .group_id = 2 }}); + try testing.expectEqual(@as(i64, 1), try countClientPrefixes(&database)); + try replaceClientPrefixes(&database, &.{}); + try testing.expectEqual(@as(i64, 0), try countClientPrefixes(&database)); +} + +test "replaceClientPrefixes rolls back on a duplicate prefix or an unknown group" { + var database = try openMigrated(); + defer database.close(); + var ids = try seedGroups(&database); + defer ids.deinit(testing.allocator); + + try replaceClientPrefixes(&database, &.{.{ .prefix = "192.168.1.0/24", .group_id = 1 }}); + + try testing.expectError(error.Constraint, replaceClientPrefixes(&database, &.{ + .{ .prefix = "10.0.0.0/8", .group_id = 2 }, + .{ .prefix = "10.0.0.0/8", .group_id = 1 }, + })); + try testing.expectError(error.Constraint, replaceClientPrefixes(&database, &.{ + .{ .prefix = "10.0.0.0/8", .group_id = 404 }, + })); + + // Both failures left the previous list in place. + var rows = try listClientPrefixRows(&database, testing.allocator); + defer rows.deinit(testing.allocator); + defer freeClientPrefixRows(testing.allocator, rows.items); + try testing.expectEqual(@as(usize, 1), rows.items.len); + try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix); +} + +fn clientRowsUnderFailure(gpa: Allocator, ids: *const IdMap) !void { + var database = try openMigrated(); + defer database.close(); + try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');"); + try seedClients(&database, ids); + try seedClientPrefixes(&database, ids); + + var rows = try listClientRows(&database, gpa); + defer rows.deinit(gpa); + defer freeClientRows(gpa, rows.items); + + const one = (try getClient(&database, gpa, rows.items[0].id)).?; + defer freeClientRow(gpa, one); + + var prefixes = try listClientPrefixRows(&database, gpa); + defer prefixes.deinit(gpa); + defer freeClientPrefixRows(gpa, prefixes.items); +} + +test "the client read surface is leak-safe under allocation failure" { + var ids: IdMap = .empty; + defer ids.deinit(testing.allocator); + try ids.put(testing.allocator, "default", 1); + try ids.put(testing.allocator, "kids", 2); + try testing.checkAllAllocationFailures(testing.allocator, clientRowsUnderFailure, .{&ids}); +} diff --git a/src/storage/repositories/crud.zig b/src/storage/repositories/crud.zig new file mode 100644 index 0000000..f73e891 --- /dev/null +++ b/src/storage/repositories/crud.zig @@ -0,0 +1,80 @@ +//! What every by-id mutation in this directory shares. +//! +//! `UPDATE ... WHERE id = ?1` and `DELETE ... WHERE id = ?1` are silent about a +//! row that is not there: SQLite reports success and touches nothing. The REST +//! layer must answer 404 instead, so every mutation runs its statement through +//! `execStrict`, which turns "touched no row" into `error.NotFound`. +//! +//! `error.Constraint` needs no helper — `Stmt.exec` already reports it, and the +//! handler layer maps it to 409. Each mutation documents which constraint of +//! `config_schema.ddl_v1` can fire. + +const std = @import("std"); + +const db = @import("../db.zig"); +const migrations = @import("../migrations.zig"); + +/// Runs a statement that must touch exactly one row. +/// +/// `Db.changes` counts the rows the *last completed* statement wrote, so it +/// must be read immediately after `exec`. SQLite counts a row an `UPDATE` +/// rewrote with identical values, so a no-op edit is not mistaken for a missing +/// row. +pub fn execStrict(database: *db.Db, stmt: *db.Stmt) db.Error!void { + try stmt.exec(); + if (database.changes() == 0) return error.NotFound; +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +fn openTable() !db.Db { + var database = try db.Db.open(":memory:", .{ .mode = .memory }); + errdefer database.close(); + try db.applyPragmas(&database, .{}); + _ = try migrations.migrate(&database); + try database.exec("INSERT INTO forward_zones (id, zone, resolver) VALUES (1, 'home.arpa', 'udp://10.0.0.1:53');"); + return database; +} + +test "execStrict passes an update that touches a row" { + var database = try openTable(); + defer database.close(); + + var stmt = try database.prepare("UPDATE forward_zones SET resolver = ?2 WHERE id = ?1"); + defer stmt.deinit(); + try stmt.bindInt(1, 1); + try stmt.bindText(2, "udp://10.0.0.2:53"); + try execStrict(&database, &stmt); +} + +test "execStrict passes an update that rewrites the same value" { + var database = try openTable(); + defer database.close(); + + var stmt = try database.prepare("UPDATE forward_zones SET resolver = ?2 WHERE id = ?1"); + defer stmt.deinit(); + try stmt.bindInt(1, 1); + try stmt.bindText(2, "udp://10.0.0.1:53"); + try execStrict(&database, &stmt); +} + +test "execStrict reports NotFound for an id no row holds" { + var database = try openTable(); + defer database.close(); + + var update = try database.prepare("UPDATE forward_zones SET resolver = 'x' WHERE id = ?1"); + defer update.deinit(); + try update.bindInt(1, 404); + try testing.expectError(error.NotFound, execStrict(&database, &update)); + + var delete = try database.prepare("DELETE FROM forward_zones WHERE id = ?1"); + defer delete.deinit(); + try delete.bindInt(1, 404); + try testing.expectError(error.NotFound, execStrict(&database, &delete)); + + try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM forward_zones")); +} diff --git a/src/storage/repositories/groups_repo.zig b/src/storage/repositories/groups_repo.zig index 782f5ca..e2451f9 100644 --- a/src/storage/repositories/groups_repo.zig +++ b/src/storage/repositories/groups_repo.zig @@ -4,9 +4,9 @@ //! stable across an import, so an export carrying them would not re-import into //! the same shape. //! -//! Only list / insert / deleteAll / count exist. Update-by-id, delete-by-id and -//! paged reads are Phase 8's REST surface; adding them now would be untested, -//! unused generality. +//! The import path is list / insert / deleteAll / count. Phase 8's REST surface +//! is the second half of this file: it speaks row ids, because that is what a +//! `/api/groups/{id}` request names. const std = @import("std"); const Allocator = std.mem.Allocator; @@ -15,6 +15,7 @@ const db = @import("../db.zig"); const migrations = @import("../migrations.zig"); const model = @import("../../config/model.zig"); const context = @import("context.zig"); +const crud = @import("crud.zig"); const IdMap = context.IdMap; const InsertContext = context.InsertContext; @@ -133,6 +134,148 @@ pub fn countGroupSources(database: *db.Db) db.Error!i64 { return database.queryInt("SELECT count(*) FROM group_sources"); } +// --------------------------------------------------------------------------- +// REST surface (milestone 8) +// --------------------------------------------------------------------------- +// +// The write shape is `model.Group`: its two fields are exactly the columns an +// operator may set, so the REST layer needs no third shape for them. + +pub const GroupRow = struct { id: i64, name: []const u8, safe_search: bool }; + +/// Same order as `listGroups`; every string is a heap copy owned by `gpa`. +pub fn listGroupRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(GroupRow) { + var stmt = try database.prepare("SELECT id, name, safe_search FROM groups ORDER BY name"); + defer stmt.deinit(); + + var out: std.ArrayList(GroupRow) = .empty; + errdefer out.deinit(gpa); + errdefer freeGroupRows(gpa, out.items); + + while (try stmt.step()) { + const name = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(name); + try out.append(gpa, .{ + .id = stmt.columnInt(0), + .name = name, + .safe_search = stmt.columnBool(2), + }); + } + return out; +} + +pub fn freeGroupRow(gpa: Allocator, row: GroupRow) void { + gpa.free(row.name); +} + +pub fn freeGroupRows(gpa: Allocator, items: []const GroupRow) void { + for (items) |item| freeGroupRow(gpa, item); +} + +pub fn getGroup(database: *db.Db, gpa: Allocator, id: i64) db.Error!?GroupRow { + var stmt = try database.prepare("SELECT id, name, safe_search FROM groups WHERE id = ?1"); + defer stmt.deinit(); + try stmt.bindInt(1, id); + if (!try stmt.step()) return null; + return .{ + .id = stmt.columnInt(0), + .name = try stmt.columnTextAlloc(gpa, 1), + .safe_search = stmt.columnBool(2), + }; +} + +/// `error.Constraint`: `groups.name` is UNIQUE. +pub fn insertGroupRow(database: *db.Db, item: model.Group) db.Error!i64 { + var stmt = try database.prepare("INSERT INTO groups (name, safe_search) VALUES (?1, ?2)"); + defer stmt.deinit(); + try stmt.bindText(1, item.name); + try stmt.bindBool(2, item.safe_search); + try stmt.exec(); + return database.lastInsertRowid(); +} + +/// `error.NotFound`: no group holds `id`. `error.Constraint`: `groups.name` is +/// UNIQUE. +pub fn updateGroup(database: *db.Db, id: i64, item: model.Group) db.Error!void { + var stmt = try database.prepare("UPDATE groups SET name = ?2, safe_search = ?3 WHERE id = ?1"); + defer stmt.deinit(); + try stmt.bindInt(1, id); + try stmt.bindText(2, item.name); + try stmt.bindBool(3, item.safe_search); + return crud.execStrict(database, &stmt); +} + +/// `error.NotFound`: no group holds `id`. `error.Constraint`: `clients.group_id` +/// references it and carries no `ON DELETE` action, so a group any client sits +/// in cannot go. `client_prefixes`, `group_sources` and `rules` cascade and +/// disappear with it. +pub fn deleteGroup(database: *db.Db, id: i64) db.Error!void { + var stmt = try database.prepare("DELETE FROM groups WHERE id = ?1"); + defer stmt.deinit(); + try stmt.bindInt(1, id); + return crud.execStrict(database, &stmt); +} + +/// The blocklist sources assigned to one group, ascending. An unknown +/// `group_id` yields an empty list, not an error: the caller that needs the +/// distinction reads the group itself. +pub fn listGroupSourceIds(database: *db.Db, gpa: Allocator, group_id: i64) db.Error!std.ArrayList(i64) { + var stmt = try database.prepare("SELECT source_id FROM group_sources WHERE group_id = ?1 ORDER BY source_id"); + defer stmt.deinit(); + try stmt.bindInt(1, group_id); + + var out: std.ArrayList(i64) = .empty; + errdefer out.deinit(gpa); + while (try stmt.step()) try out.append(gpa, stmt.columnInt(0)); + return out; +} + +/// Replaces one group's whole source assignment inside a transaction, so a +/// caller never observes the group with half a set. +/// +/// The assignment is a set: an id repeated in `source_ids` is written once. +/// Order does not survive, and re-running the call with the same ids is a no-op +/// as far as any reader can tell. +/// +/// `error.NotFound`: no group holds `group_id` — checked explicitly, because an +/// empty `source_ids` writes nothing and would otherwise report success for a +/// group that does not exist. `error.Constraint`: an id in `source_ids` names no +/// `blocklist_sources` row. +pub fn setGroupSources(database: *db.Db, group_id: i64, source_ids: []const i64) db.Error!void { + var tx = try db.Tx.begin(database); + errdefer tx.rollback(); + + if (!try groupExists(database, group_id)) return error.NotFound; + + { + var delete = try database.prepare("DELETE FROM group_sources WHERE group_id = ?1"); + defer delete.deinit(); + try delete.bindInt(1, group_id); + try delete.exec(); + } + + var insert = try database.prepare("INSERT INTO group_sources (group_id, source_id) VALUES (?1, ?2)"); + defer insert.deinit(); + for (source_ids, 0..) |source_id, i| { + if (std.mem.indexOfScalar(i64, source_ids[0..i], source_id) != null) continue; + // `reset` clears the bindings too, so both parameters are bound again + // on every pass. + try insert.reset(); + try insert.bindInt(1, group_id); + try insert.bindInt(2, source_id); + try insert.exec(); + } + + try tx.commit(); +} + +fn groupExists(database: *db.Db, id: i64) db.Error!bool { + var stmt = try database.prepare("SELECT 1 FROM groups WHERE id = ?1"); + defer stmt.deinit(); + try stmt.bindInt(1, id); + return stmt.step(); +} + // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- @@ -284,3 +427,207 @@ test "listGroupSources is leak-safe under allocation failure" { defer ids.deinit(testing.allocator); try testing.checkAllAllocationFailures(testing.allocator, listGroupSourcesUnderFailure, .{&ids}); } + +// --- REST surface ---------------------------------------------------------- + +/// Two sources with known ids, for the group-source assignment tests. +fn seedSourceRows(database: *db.Db) !void { + try database.exec( + \\INSERT INTO blocklist_sources (id, url, name) VALUES + \\ (10, 'https://a.example/list.txt', 'A'), + \\ (20, 'https://b.example/list.txt', 'B'), + \\ (30, 'https://c.example/list.txt', 'C'); + ); +} + +test "a group round-trips through insert, get, list, update and delete" { + var database = try openMigrated(); + defer database.close(); + + const id = try insertGroupRow(&database, .{ .name = "kids", .safe_search = true }); + try testing.expect(id > 1); + + const fetched = (try getGroup(&database, testing.allocator, id)).?; + defer freeGroupRow(testing.allocator, fetched); + try testing.expectEqual(id, fetched.id); + try testing.expectEqualStrings("kids", fetched.name); + try testing.expect(fetched.safe_search); + + var rows = try listGroupRows(&database, testing.allocator); + defer rows.deinit(testing.allocator); + defer freeGroupRows(testing.allocator, rows.items); + try testing.expectEqual(@as(usize, 2), rows.items.len); + try testing.expectEqualStrings("default", rows.items[0].name); + try testing.expectEqual(@as(i64, 1), rows.items[0].id); + try testing.expectEqualStrings("kids", rows.items[1].name); + try testing.expectEqual(id, rows.items[1].id); + + try updateGroup(&database, id, .{ .name = "teens", .safe_search = false }); + const updated = (try getGroup(&database, testing.allocator, id)).?; + defer freeGroupRow(testing.allocator, updated); + try testing.expectEqualStrings("teens", updated.name); + try testing.expect(!updated.safe_search); + + try deleteGroup(&database, id); + try testing.expectEqual(@as(?GroupRow, null), try getGroup(&database, testing.allocator, id)); + try testing.expectEqual(@as(i64, 1), try countGroups(&database)); +} + +test "update and delete report NotFound for an id no group holds" { + var database = try openMigrated(); + defer database.close(); + + try testing.expectError(error.NotFound, updateGroup(&database, 404, .{ .name = "ghost" })); + try testing.expectError(error.NotFound, deleteGroup(&database, 404)); + try testing.expectEqual(@as(?GroupRow, null), try getGroup(&database, testing.allocator, 404)); +} + +test "a duplicate group name surfaces as error.Constraint on insert and on update" { + var database = try openMigrated(); + defer database.close(); + + const id = try insertGroupRow(&database, .{ .name = "kids" }); + try testing.expectError(error.Constraint, insertGroupRow(&database, .{ .name = "kids" })); + try testing.expectError(error.Constraint, updateGroup(&database, id, .{ .name = "default" })); +} + +test "deleting a group a client sits in surfaces as error.Constraint" { + var database = try openMigrated(); + defer database.close(); + + const id = try insertGroupRow(&database, .{ .name = "kids" }); + try database.exec( + \\INSERT INTO clients (ip, group_id, first_seen, last_seen) + \\VALUES ('192.168.1.9', 2, 1, 1); + ); + try testing.expectError(error.Constraint, deleteGroup(&database, id)); + try testing.expectEqual(@as(i64, 2), try countGroups(&database)); +} + +test "deleting a group takes its rules, prefixes and source assignment with it" { + var database = try openMigrated(); + defer database.close(); + try seedSourceRows(&database); + + const id = try insertGroupRow(&database, .{ .name = "kids" }); + try setGroupSources(&database, id, &.{ 10, 20 }); + try database.exec("INSERT INTO rules (group_id, pattern, kind, action, created_at) VALUES (2, 'x.example', 'exact', 'block', 1);"); + try database.exec("INSERT INTO client_prefixes (prefix, group_id) VALUES ('10.0.0.0/8', 2);"); + + try deleteGroup(&database, id); + try testing.expectEqual(@as(i64, 0), try countGroupSources(&database)); + try testing.expectEqual(@as(i64, 0), try database.queryInt("SELECT count(*) FROM rules")); + try testing.expectEqual(@as(i64, 0), try database.queryInt("SELECT count(*) FROM client_prefixes")); +} + +fn sourceIds(database: *db.Db, group_id: i64) ![]i64 { + var list = try listGroupSourceIds(database, testing.allocator, group_id); + return list.toOwnedSlice(testing.allocator); +} + +test "setGroupSources replaces the whole set and repeats without effect" { + var database = try openMigrated(); + defer database.close(); + try seedSourceRows(&database); + + try setGroupSources(&database, 1, &.{ 20, 10 }); + { + const ids = try sourceIds(&database, 1); + defer testing.allocator.free(ids); + try testing.expectEqualSlices(i64, &.{ 10, 20 }, ids); + } + + // Same set again: the rows are rewritten, the observable state is not. + try setGroupSources(&database, 1, &.{ 10, 20 }); + { + const ids = try sourceIds(&database, 1); + defer testing.allocator.free(ids); + try testing.expectEqualSlices(i64, &.{ 10, 20 }, ids); + } + try testing.expectEqual(@as(i64, 2), try countGroupSources(&database)); + + // A different set replaces, it does not merge. + try setGroupSources(&database, 1, &.{30}); + { + const ids = try sourceIds(&database, 1); + defer testing.allocator.free(ids); + try testing.expectEqualSlices(i64, &.{30}, ids); + } + + // The empty set clears it. + try setGroupSources(&database, 1, &.{}); + try testing.expectEqual(@as(i64, 0), try countGroupSources(&database)); +} + +test "setGroupSources writes a repeated id once" { + var database = try openMigrated(); + defer database.close(); + try seedSourceRows(&database); + + try setGroupSources(&database, 1, &.{ 10, 10, 20, 10 }); + const ids = try sourceIds(&database, 1); + defer testing.allocator.free(ids); + try testing.expectEqualSlices(i64, &.{ 10, 20 }, ids); +} + +test "setGroupSources leaves other groups alone" { + var database = try openMigrated(); + defer database.close(); + try seedSourceRows(&database); + const kids = try insertGroupRow(&database, .{ .name = "kids" }); + + try setGroupSources(&database, 1, &.{10}); + try setGroupSources(&database, kids, &.{ 20, 30 }); + try setGroupSources(&database, kids, &.{20}); + + const default_ids = try sourceIds(&database, 1); + defer testing.allocator.free(default_ids); + try testing.expectEqualSlices(i64, &.{10}, default_ids); + const kids_ids = try sourceIds(&database, kids); + defer testing.allocator.free(kids_ids); + try testing.expectEqualSlices(i64, &.{20}, kids_ids); +} + +test "setGroupSources reports NotFound for a group that does not exist" { + var database = try openMigrated(); + defer database.close(); + try seedSourceRows(&database); + + try testing.expectError(error.NotFound, setGroupSources(&database, 404, &.{10})); + // Including the case where the empty set writes nothing at all. + try testing.expectError(error.NotFound, setGroupSources(&database, 404, &.{})); +} + +test "setGroupSources rolls back and reports Constraint for an unknown source id" { + var database = try openMigrated(); + defer database.close(); + try seedSourceRows(&database); + try setGroupSources(&database, 1, &.{10}); + + try testing.expectError(error.Constraint, setGroupSources(&database, 1, &.{ 20, 999 })); + + // The prior assignment survived: the failed call wrote nothing. + const ids = try sourceIds(&database, 1); + defer testing.allocator.free(ids); + try testing.expectEqualSlices(i64, &.{10}, ids); +} + +fn groupRowsUnderFailure(gpa: Allocator) !void { + var database = try openMigrated(); + defer database.close(); + try seedGroups(&database); + + var rows = try listGroupRows(&database, gpa); + defer rows.deinit(gpa); + defer freeGroupRows(gpa, rows.items); + + const one = (try getGroup(&database, gpa, 1)).?; + defer freeGroupRow(gpa, one); + + var ids = try listGroupSourceIds(&database, gpa, 1); + defer ids.deinit(gpa); +} + +test "the group read surface is leak-safe under allocation failure" { + try testing.checkAllAllocationFailures(testing.allocator, groupRowsUnderFailure, .{}); +} diff --git a/src/storage/repositories/local_repo.zig b/src/storage/repositories/local_repo.zig index 20f7e8a..989d395 100644 --- a/src/storage/repositories/local_repo.zig +++ b/src/storage/repositories/local_repo.zig @@ -1,6 +1,8 @@ //! `local_records` and `forward_zones`. //! -//! Only list / insert / deleteAll / count exist. +//! The import path is list / insert / deleteAll / count. Phase 8's REST surface +//! 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. const std = @import("std"); const Allocator = std.mem.Allocator; @@ -9,6 +11,7 @@ const db = @import("../db.zig"); const migrations = @import("../migrations.zig"); const model = @import("../../config/model.zig"); const context = @import("context.zig"); +const crud = @import("crud.zig"); const InsertContext = context.InsertContext; @@ -121,6 +124,185 @@ pub fn countForwardZones(database: *db.Db) db.Error!i64 { return database.queryInt("SELECT count(*) FROM forward_zones"); } +// --------------------------------------------------------------------------- +// REST surface (milestone 8) +// --------------------------------------------------------------------------- +// +// Neither table references another, so the write shapes are `model.LocalRecord` +// and `model.ForwardZone` unchanged: their fields are exactly the columns. + +pub const LocalRecordRow = struct { + id: i64, + name: []const u8, + rtype: model.RecordType, + value: []const u8, + ttl: u32, +}; + +const list_local_record_rows_sql = + \\SELECT id, name, rtype, value, ttl FROM local_records ORDER BY name, rtype, value +; + +/// Same order as `listLocalRecords`; every string is a heap copy owned by `gpa`. +pub fn listLocalRecordRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(LocalRecordRow) { + var stmt = try database.prepare(list_local_record_rows_sql); + defer stmt.deinit(); + + var out: std.ArrayList(LocalRecordRow) = .empty; + errdefer out.deinit(gpa); + errdefer freeLocalRecordRows(gpa, out.items); + + while (try stmt.step()) { + const row = try readLocalRecordRow(&stmt, gpa); + errdefer freeLocalRecordRow(gpa, row); + try out.append(gpa, row); + } + return out; +} + +pub fn freeLocalRecordRow(gpa: Allocator, row: LocalRecordRow) void { + gpa.free(row.name); + gpa.free(row.value); +} + +pub fn freeLocalRecordRows(gpa: Allocator, items: []const LocalRecordRow) void { + for (items) |item| freeLocalRecordRow(gpa, item); +} + +pub fn getLocalRecord(database: *db.Db, gpa: Allocator, id: i64) db.Error!?LocalRecordRow { + var stmt = try database.prepare("SELECT id, name, rtype, value, ttl FROM local_records WHERE id = ?1"); + defer stmt.deinit(); + try stmt.bindInt(1, id); + if (!try stmt.step()) return null; + return try readLocalRecordRow(&stmt, gpa); +} + +fn readLocalRecordRow(stmt: *db.Stmt, gpa: Allocator) db.Error!LocalRecordRow { + // The DDL's CHECK constraint makes the decode total for any row nxdns + // wrote; `error.Mismatch` covers a row that something else wrote, and the + // same goes for a `ttl` outside `u32`. + const rtype = model.RecordType.fromDb(stmt.columnText(2)) orelse return error.Mismatch; + const ttl = std.math.cast(u32, stmt.columnInt(4)) orelse return error.Mismatch; + const name = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(name); + const value = try stmt.columnTextAlloc(gpa, 3); + errdefer gpa.free(value); + return .{ .id = stmt.columnInt(0), .name = name, .rtype = rtype, .value = value, .ttl = ttl }; +} + +/// `error.Constraint`: `local_records` declares `UNIQUE(name, rtype, value)`, so +/// the same answer cannot be stored twice — a second TTL for one record would be +/// two truths. +pub fn insertLocalRecordRow(database: *db.Db, item: model.LocalRecord) db.Error!i64 { + var stmt = try database.prepare(insert_local_record_sql); + defer stmt.deinit(); + try stmt.bindText(1, item.name); + try stmt.bindText(2, item.rtype.toDb()); + try stmt.bindText(3, item.value); + try stmt.bindInt(4, item.ttl); + try stmt.exec(); + return database.lastInsertRowid(); +} + +/// `error.NotFound`: no record holds `id`. `error.Constraint`: +/// `UNIQUE(name, rtype, value)`. +pub fn updateLocalRecord(database: *db.Db, id: i64, item: model.LocalRecord) db.Error!void { + var stmt = try database.prepare( + "UPDATE local_records SET name = ?2, rtype = ?3, value = ?4, ttl = ?5 WHERE id = ?1", + ); + defer stmt.deinit(); + try stmt.bindInt(1, id); + try stmt.bindText(2, item.name); + try stmt.bindText(3, item.rtype.toDb()); + try stmt.bindText(4, item.value); + try stmt.bindInt(5, item.ttl); + return crud.execStrict(database, &stmt); +} + +/// `error.NotFound`: no record holds `id`. Nothing references `local_records`, +/// so a delete cannot violate a constraint. +pub fn deleteLocalRecord(database: *db.Db, id: i64) db.Error!void { + var stmt = try database.prepare("DELETE FROM local_records WHERE id = ?1"); + defer stmt.deinit(); + try stmt.bindInt(1, id); + return crud.execStrict(database, &stmt); +} + +pub const ForwardZoneRow = struct { id: i64, zone: []const u8, resolver: []const u8 }; + +/// Same order as `listForwardZones`; every string is a heap copy owned by `gpa`. +pub fn listForwardZoneRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ForwardZoneRow) { + var stmt = try database.prepare("SELECT id, zone, resolver FROM forward_zones ORDER BY zone"); + defer stmt.deinit(); + + var out: std.ArrayList(ForwardZoneRow) = .empty; + errdefer out.deinit(gpa); + errdefer freeForwardZoneRows(gpa, out.items); + + while (try stmt.step()) { + const row = try readForwardZoneRow(&stmt, gpa); + errdefer freeForwardZoneRow(gpa, row); + try out.append(gpa, row); + } + return out; +} + +pub fn freeForwardZoneRow(gpa: Allocator, row: ForwardZoneRow) void { + gpa.free(row.zone); + gpa.free(row.resolver); +} + +pub fn freeForwardZoneRows(gpa: Allocator, items: []const ForwardZoneRow) void { + for (items) |item| freeForwardZoneRow(gpa, item); +} + +pub fn getForwardZone(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ForwardZoneRow { + var stmt = try database.prepare("SELECT id, zone, resolver FROM forward_zones WHERE id = ?1"); + defer stmt.deinit(); + try stmt.bindInt(1, id); + if (!try stmt.step()) return null; + return try readForwardZoneRow(&stmt, gpa); +} + +fn readForwardZoneRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ForwardZoneRow { + const zone = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(zone); + const resolver = try stmt.columnTextAlloc(gpa, 2); + errdefer gpa.free(resolver); + return .{ .id = stmt.columnInt(0), .zone = zone, .resolver = resolver }; +} + +/// `error.Constraint`: `forward_zones.zone` is UNIQUE — one zone has one +/// resolver. +pub fn insertForwardZoneRow(database: *db.Db, item: model.ForwardZone) db.Error!i64 { + var stmt = try database.prepare("INSERT INTO forward_zones (zone, resolver) VALUES (?1, ?2)"); + defer stmt.deinit(); + try stmt.bindText(1, item.zone); + try stmt.bindText(2, item.resolver); + try stmt.exec(); + return database.lastInsertRowid(); +} + +/// `error.NotFound`: no zone holds `id`. `error.Constraint`: +/// `forward_zones.zone` is UNIQUE. +pub fn updateForwardZone(database: *db.Db, id: i64, item: model.ForwardZone) db.Error!void { + var stmt = try database.prepare("UPDATE forward_zones SET zone = ?2, resolver = ?3 WHERE id = ?1"); + defer stmt.deinit(); + try stmt.bindInt(1, id); + try stmt.bindText(2, item.zone); + try stmt.bindText(3, item.resolver); + return crud.execStrict(database, &stmt); +} + +/// `error.NotFound`: no zone holds `id`. Nothing references `forward_zones`, so +/// a delete cannot violate a constraint. +pub fn deleteForwardZone(database: *db.Db, id: i64) db.Error!void { + var stmt = try database.prepare("DELETE FROM forward_zones WHERE id = ?1"); + defer stmt.deinit(); + try stmt.bindInt(1, id); + return crud.execStrict(database, &stmt); +} + // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- @@ -253,3 +435,161 @@ fn listForwardZonesUnderFailure(gpa: Allocator) !void { test "listForwardZones is leak-safe under allocation failure" { try testing.checkAllAllocationFailures(testing.allocator, listForwardZonesUnderFailure, .{}); } + +// --- REST surface ---------------------------------------------------------- + +test "a local record round-trips through insert, get, list, update and delete" { + var database = try openMigrated(); + defer database.close(); + + const id = try insertLocalRecordRow(&database, .{ + .name = "nas.home.arpa", + .rtype = .a, + .value = "192.168.1.5", + }); + + const fetched = (try getLocalRecord(&database, testing.allocator, id)).?; + defer freeLocalRecordRow(testing.allocator, fetched); + try testing.expectEqual(id, fetched.id); + try testing.expectEqualStrings("nas.home.arpa", fetched.name); + try testing.expectEqual(model.RecordType.a, fetched.rtype); + try testing.expectEqualStrings("192.168.1.5", fetched.value); + try testing.expectEqual(@as(u32, 300), fetched.ttl); + + try updateLocalRecord(&database, id, .{ + .name = "nas.home.arpa", + .rtype = .a, + .value = "192.168.1.6", + .ttl = 60, + }); + const updated = (try getLocalRecord(&database, testing.allocator, id)).?; + defer freeLocalRecordRow(testing.allocator, updated); + try testing.expectEqualStrings("192.168.1.6", updated.value); + try testing.expectEqual(@as(u32, 60), updated.ttl); + + var rows = try listLocalRecordRows(&database, testing.allocator); + defer rows.deinit(testing.allocator); + defer freeLocalRecordRows(testing.allocator, rows.items); + try testing.expectEqual(@as(usize, 1), rows.items.len); + try testing.expectEqual(id, rows.items[0].id); + + try deleteLocalRecord(&database, id); + try testing.expectEqual(@as(?LocalRecordRow, null), try getLocalRecord(&database, testing.allocator, id)); + try testing.expectEqual(@as(i64, 0), try countLocalRecords(&database)); +} + +test "local record update and delete report NotFound for an id no row holds" { + var database = try openMigrated(); + defer database.close(); + + const item: model.LocalRecord = .{ .name = "x.home.arpa", .rtype = .a, .value = "10.0.0.1" }; + try testing.expectError(error.NotFound, updateLocalRecord(&database, 404, item)); + try testing.expectError(error.NotFound, deleteLocalRecord(&database, 404)); + try testing.expectEqual(@as(?LocalRecordRow, null), try getLocalRecord(&database, testing.allocator, 404)); +} + +test "a duplicate name, rtype and value surfaces as error.Constraint" { + var database = try openMigrated(); + defer database.close(); + + const first: model.LocalRecord = .{ .name = "nas.home.arpa", .rtype = .a, .value = "192.168.1.5" }; + _ = try insertLocalRecordRow(&database, first); + // The TTL is not part of the UNIQUE key, so a second TTL is still a clash. + try testing.expectError(error.Constraint, insertLocalRecordRow(&database, .{ + .name = "nas.home.arpa", + .rtype = .a, + .value = "192.168.1.5", + .ttl = 60, + })); + + const other = try insertLocalRecordRow(&database, .{ + .name = "nas.home.arpa", + .rtype = .aaaa, + .value = "fd00::5", + }); + try testing.expectError(error.Constraint, updateLocalRecord(&database, other, first)); + try testing.expectEqual(@as(i64, 2), try countLocalRecords(&database)); +} + +test "a forward zone round-trips through insert, get, list, update and delete" { + var database = try openMigrated(); + defer database.close(); + + const id = try insertForwardZoneRow(&database, .{ + .zone = "home.arpa", + .resolver = "udp://192.168.1.1:53", + }); + + const fetched = (try getForwardZone(&database, testing.allocator, id)).?; + defer freeForwardZoneRow(testing.allocator, fetched); + try testing.expectEqual(id, fetched.id); + try testing.expectEqualStrings("home.arpa", fetched.zone); + try testing.expectEqualStrings("udp://192.168.1.1:53", fetched.resolver); + + try updateForwardZone(&database, id, .{ .zone = "lab.example", .resolver = "tcp://[fd00::1]:53" }); + const updated = (try getForwardZone(&database, testing.allocator, id)).?; + defer freeForwardZoneRow(testing.allocator, updated); + try testing.expectEqualStrings("lab.example", updated.zone); + try testing.expectEqualStrings("tcp://[fd00::1]:53", updated.resolver); + + var rows = try listForwardZoneRows(&database, testing.allocator); + defer rows.deinit(testing.allocator); + defer freeForwardZoneRows(testing.allocator, rows.items); + try testing.expectEqual(@as(usize, 1), rows.items.len); + try testing.expectEqual(id, rows.items[0].id); + + try deleteForwardZone(&database, id); + try testing.expectEqual(@as(?ForwardZoneRow, null), try getForwardZone(&database, testing.allocator, id)); + try testing.expectEqual(@as(i64, 0), try countForwardZones(&database)); +} + +test "forward zone update and delete report NotFound for an id no row holds" { + var database = try openMigrated(); + defer database.close(); + + const item: model.ForwardZone = .{ .zone = "home.arpa", .resolver = "udp://10.0.0.1:53" }; + try testing.expectError(error.NotFound, updateForwardZone(&database, 404, item)); + try testing.expectError(error.NotFound, deleteForwardZone(&database, 404)); + try testing.expectEqual(@as(?ForwardZoneRow, null), try getForwardZone(&database, testing.allocator, 404)); +} + +test "a duplicate zone surfaces as error.Constraint on insert and on update" { + var database = try openMigrated(); + defer database.close(); + + _ = try insertForwardZoneRow(&database, .{ .zone = "home.arpa", .resolver = "udp://10.0.0.1:53" }); + const other = try insertForwardZoneRow(&database, .{ .zone = "lab.example", .resolver = "udp://10.0.0.2:53" }); + + try testing.expectError(error.Constraint, insertForwardZoneRow(&database, .{ + .zone = "home.arpa", + .resolver = "udp://10.0.0.3:53", + })); + try testing.expectError(error.Constraint, updateForwardZone(&database, other, .{ + .zone = "home.arpa", + .resolver = "udp://10.0.0.2:53", + })); + try testing.expectEqual(@as(i64, 2), try countForwardZones(&database)); +} + +fn localRowsUnderFailure(gpa: Allocator) !void { + var database = try openMigrated(); + defer database.close(); + try seedLocalRecords(&database); + try seedForwardZones(&database); + + var records = try listLocalRecordRows(&database, gpa); + defer records.deinit(gpa); + defer freeLocalRecordRows(gpa, records.items); + const record = (try getLocalRecord(&database, gpa, records.items[0].id)).?; + defer freeLocalRecordRow(gpa, record); + + var zones = try listForwardZoneRows(&database, gpa); + defer zones.deinit(gpa); + defer freeForwardZoneRows(gpa, zones.items); + const zone = (try getForwardZone(&database, gpa, zones.items[0].id)).?; + defer freeForwardZoneRow(gpa, zone); +} + +test "the local read surface is leak-safe under allocation failure" { + try testing.checkAllAllocationFailures(testing.allocator, localRowsUnderFailure, .{}); +} diff --git a/src/storage/repositories/queries_repo.zig b/src/storage/repositories/queries_repo.zig index 04c64e2..3e21ac0 100644 --- a/src/storage/repositories/queries_repo.zig +++ b/src/storage/repositories/queries_repo.zig @@ -2,8 +2,9 @@ //! //! Two shapes live here. The free functions follow the milestone-4 repository //! idiom — prepare, use, finalize — because retention runs them a handful of -//! times per day. The flush loop is the one hot path in the program, so it gets -//! `BatchWriter`, which owns its three statements for its whole life +//! times per day, and the API read layer at the bottom of the file runs once +//! per HTTP request. The flush loop is the one hot path in the program, so it +//! gets `BatchWriter`, which owns its three statements for its whole life //! (`db.zig:360` names this file as the reason `db.zig` carries no statement //! cache). //! @@ -15,6 +16,7 @@ //! decides what a failed batch means. const std = @import("std"); +const Allocator = std.mem.Allocator; const db = @import("../db.zig"); @@ -181,6 +183,294 @@ pub fn countDomains(database: *db.Db) db.Error!i64 { return database.queryInt("SELECT count(*) FROM domains"); } +// --------------------------------------------------------------------------- +// the API read layer (`GET /api/queries`, `/api/stats`, `/api/stats/timeseries`) +// --------------------------------------------------------------------------- + +/// One row of `GET /api/queries`, joined back through the `domains` dimension. +/// +/// `block_reason` and `upstream` are nullable columns, and a NULL reads as `""` +/// — the same convention `Stmt.columnText` already uses. Neither column is ever +/// written as an empty string (a reason is a word, an upstream is a URL), so the +/// mapping loses nothing and the API layer can treat `""` as "absent". +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, +}; + +/// Every field is an independent narrowing; `null` means "do not filter on it". +/// +/// `since` is inclusive and `until` is exclusive, so adjacent windows tile +/// without double-counting a row on the boundary. +pub const QueryFilter = struct { + limit: u32 = 100, + /// Keyset cursor: only rows with a strictly smaller `id`. Rows come back + /// newest-first, so this is the id of the last row of the previous page. + before: ?i64 = null, + /// Matched case-insensitively for ASCII, which is what SQLite's `LIKE` + /// does and what a domain search wants. + domain_substring: ?[]const u8 = null, + client: ?[]const u8 = null, + blocked: ?bool = null, + since: ?i64 = null, + until: ?i64 = null, +}; + +/// Ruling 11 caps the page at 1000; the repository enforces it too, so a caller +/// that forgets cannot ask this connection for the whole table. +pub const max_limit: u32 = 1000; + +const select_head = + \\SELECT q.id, q.timestamp, d.domain, q.client_ip, q.qtype, q.blocked, + \\ q.block_reason, q.response_time_us, q.cache_hit, q.upstream + \\ FROM query_log q JOIN domains d ON d.id = q.domain_id +; + +/// The escape character of `where_domain`. SQLite does not give string literals +/// C escapes, so `'\'` in the SQL text is one backslash. +const like_escape = '\\'; + +const where_before = " q.id < ?"; +const where_domain = " d.domain LIKE ? ESCAPE '\\'"; +const where_client = " q.client_ip = ?"; +const where_blocked = " q.blocked = ?"; +const where_since = " q.timestamp >= ?"; +const where_until = " q.timestamp < ?"; +const select_tail = " ORDER BY q.id DESC LIMIT ?"; + +const where_keyword = " WHERE"; +const and_keyword = " AND"; + +/// Assembles the statement from the fixed fragments above and nothing else. +/// +/// **No value ever reaches this buffer.** Every filter contributes a `?` and is +/// bound afterwards, in the order the predicates were appended: an unnumbered +/// parameter takes the next free index, so append order and bind order are the +/// same single contract. +const Sql = struct { + /// `where_keyword` is longer than `and_keyword` and is used at most once, + /// so counting six of it bounds every reachable combination. + const capacity = select_head.len + 6 * where_keyword.len + select_tail.len + + where_before.len + where_domain.len + where_client.len + + where_blocked.len + where_since.len + where_until.len; + + buf: [capacity]u8 = undefined, + len: usize = 0, + has_where: bool = false, + + fn put(self: *Sql, fragment: []const u8) void { + @memcpy(self.buf[self.len..][0..fragment.len], fragment); + self.len += fragment.len; + } + + fn predicate(self: *Sql, fragment: []const u8) void { + self.put(if (self.has_where) and_keyword else where_keyword); + self.has_where = true; + self.put(fragment); + } + + fn text(self: *const Sql) []const u8 { + return self.buf[0..self.len]; + } +}; + +/// Rows come back newest-first (`id DESC`). Every string is allocated from +/// `arena`, including the list's own storage, so the caller frees the whole +/// result by resetting the arena — there is nothing to unwind on failure. +pub fn selectQueries(database: *db.Db, arena: Allocator, filter: QueryFilter) db.Error!std.ArrayList(QueryRow) { + var sql: Sql = .{}; + sql.put(select_head); + if (filter.before != null) sql.predicate(where_before); + if (filter.domain_substring != null) sql.predicate(where_domain); + if (filter.client != null) sql.predicate(where_client); + if (filter.blocked != null) sql.predicate(where_blocked); + if (filter.since != null) sql.predicate(where_since); + if (filter.until != null) sql.predicate(where_until); + sql.put(select_tail); + + var stmt = try database.prepare(sql.text()); + defer stmt.deinit(); + + var idx: c_int = 0; + if (filter.before) |v| { + idx += 1; + try stmt.bindInt(idx, v); + } + if (filter.domain_substring) |v| { + idx += 1; + try stmt.bindText(idx, try likePattern(arena, v)); + } + if (filter.client) |v| { + idx += 1; + try stmt.bindText(idx, v); + } + if (filter.blocked) |v| { + idx += 1; + try stmt.bindBool(idx, v); + } + if (filter.since) |v| { + idx += 1; + try stmt.bindInt(idx, v); + } + if (filter.until) |v| { + idx += 1; + try stmt.bindInt(idx, v); + } + idx += 1; + try stmt.bindInt(idx, @min(filter.limit, max_limit)); + + var out: std.ArrayList(QueryRow) = .empty; + while (try stmt.step()) { + try out.append(arena, .{ + .id = stmt.columnInt(0), + .ts = stmt.columnInt(1), + .domain = try stmt.columnTextAlloc(arena, 2), + .client_ip = try stmt.columnTextAlloc(arena, 3), + .qtype = if (stmt.isNull(4)) null else std.math.cast(u16, stmt.columnInt(4)) orelse + return error.Mismatch, + .blocked = stmt.columnBool(5), + .block_reason = try stmt.columnTextAlloc(arena, 6), + .response_time_us = if (stmt.isNull(7)) null else stmt.columnInt(7), + .cache_hit = if (stmt.isNull(8)) null else stmt.columnBool(8), + .upstream = try stmt.columnTextAlloc(arena, 9), + }); + } + return out; +} + +/// Wraps `needle` in `%` and neutralises the two `LIKE` metacharacters, so a +/// user searching for `a_b` gets domains containing `a_b` and not domains +/// containing `axb`. The escape character escapes itself. +fn likePattern(arena: Allocator, needle: []const u8) Allocator.Error![]const u8 { + var out: std.ArrayList(u8) = try .initCapacity(arena, needle.len * 2 + 2); + out.appendAssumeCapacity('%'); + for (needle) |ch| { + if (ch == '%' or ch == '_' or ch == like_escape) out.appendAssumeCapacity(like_escape); + out.appendAssumeCapacity(ch); + } + out.appendAssumeCapacity('%'); + return out.items; +} + +/// The `/api/stats` rollup for one period. `avg_response_time_us` is `null` when +/// no row in the window recorded a response time. +pub const StatsTotals = struct { + queries: u64, + blocked: u64, + cached: u64, + distinct_clients: u64, + avg_response_time_us: ?i64, +}; + +/// The mean is derived from a sum and a count rather than SQL's `avg`, which +/// returns REAL: `Stmt` reads integers, and integer microseconds are exact. +const stats_totals_sql = + \\SELECT count(*), + \\ coalesce(sum(blocked <> 0), 0), + \\ coalesce(sum(cache_hit = 1), 0), + \\ count(DISTINCT client_ip), + \\ coalesce(sum(response_time_us), 0), + \\ count(response_time_us) + \\ FROM query_log + \\ WHERE timestamp >= ?1 AND timestamp < ?2 +; + +/// Aggregates `[since, until)`. An empty window is all zeros with a null mean, +/// not an error. +pub fn statsTotals(database: *db.Db, since: i64, until: i64) db.Error!StatsTotals { + var stmt = try database.prepare(stats_totals_sql); + defer stmt.deinit(); + try stmt.bindInt(1, since); + try stmt.bindInt(2, until); + + // A bare aggregate always produces exactly one row; no row means the + // statement is not the one this function prepared. + if (!try stmt.step()) return error.Misuse; + + const timed = stmt.columnInt(5); + return .{ + .queries = try countOf(stmt.columnInt(0)), + .blocked = try countOf(stmt.columnInt(1)), + .cached = try countOf(stmt.columnInt(2)), + .distinct_clients = try countOf(stmt.columnInt(3)), + .avg_response_time_us = if (timed == 0) null else @divTrunc(stmt.columnInt(4), timed), + }; +} + +/// `count` and `sum` over non-negative columns cannot go negative; a negative +/// value means the row came from something other than this schema. +fn countOf(value: i64) db.Error!u64 { + if (value < 0) return error.Mismatch; + return @intCast(value); +} + +/// One bucket of `/api/stats/timeseries`. `ts` is the bucket's inclusive start. +pub const Bucket = struct { + ts: i64, + queries: u64, + blocked: u64, + cached: u64, +}; + +const timeseries_sql = + \\SELECT (timestamp - ?1) / ?2, + \\ count(*), + \\ coalesce(sum(blocked <> 0), 0), + \\ coalesce(sum(cache_hit = 1), 0) + \\ FROM query_log + \\ WHERE timestamp >= ?1 AND timestamp < ?3 + \\ GROUP BY 1 +; + +/// Fills `out` with `out.len` buckets of `bucket_seconds` each, covering +/// `[since, since + bucket_seconds * out.len)`, and returns how many it wrote. +/// +/// Every bucket is present: a window with no rows in it is written with zeros +/// rather than skipped, so the caller charts a contiguous axis without +/// reconstructing the gaps. Buckets are aligned to `since`, so the caller — +/// which knows the period grammar of ruling 13 — owns UTC alignment by choosing +/// `since`. +pub fn timeseries(database: *db.Db, since: i64, bucket_seconds: u32, out: []Bucket) db.Error!usize { + if (out.len == 0) return 0; + // Both are caller bugs, not runtime conditions: a zero width would make the + // SQL divide by zero (SQLite yields NULL, silently emptying the chart), and + // a window that does not fit i64 cannot be asked about. + if (bucket_seconds == 0) return error.Misuse; + const width: i64 = bucket_seconds; + const span = std.math.mul(i64, width, std.math.cast(i64, out.len) orelse + return error.Misuse) catch return error.Misuse; + const until = std.math.add(i64, since, span) catch return error.Misuse; + + for (out, 0..) |*bucket, i| { + bucket.* = .{ .ts = since + width * @as(i64, @intCast(i)), .queries = 0, .blocked = 0, .cached = 0 }; + } + + var stmt = try database.prepare(timeseries_sql); + defer stmt.deinit(); + try stmt.bindInt(1, since); + try stmt.bindInt(2, width); + try stmt.bindInt(3, until); + + while (try stmt.step()) { + // The WHERE clause already bounds the index to `out`; the check is + // cheap and keeps a schema surprise from writing past the slice. + const index = std.math.cast(usize, stmt.columnInt(0)) orelse return error.Mismatch; + if (index >= out.len) return error.Mismatch; + out[index].queries = try countOf(stmt.columnInt(1)); + out[index].blocked = try countOf(stmt.columnInt(2)); + out[index].cached = try countOf(stmt.columnInt(3)); + } + return out.len; +} + // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- @@ -466,3 +756,379 @@ test "checkpointTruncate and vacuum run against a WAL file database" { try testing.expectEqual(@as(i64, 1), try countRows(&database)); try testing.expectEqual(@as(i64, 2), try countDomains(&database)); } + +// --- the read layer ------------------------------------------------------- + +/// `BatchWriter` assigns `query_log.id` in the order it is handed the rows, so +/// every test below knows the id of each seeded row: the nth row of the nth +/// batch has id n. +fn seed(database: *db.Db, rows: []const Row) !void { + var writer = try BatchWriter.init(database); + defer writer.deinit(); + try writer.writeBatch(rows); +} + +fn ids(rows: []const QueryRow, out: []i64) []const i64 { + for (rows, 0..) |row, i| out[i] = row.id; + return out[0..rows.len]; +} + +test "selectQueries returns the newest row first and reads every column" { + var database = try openLog(); + defer database.close(); + var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena_state.deinit(); + + try seed(&database, &.{ + .{ + .timestamp = 10, + .domain = "ads.example.net", + .client_ip = "192.0.2.10", + .qtype = 28, + .blocked = true, + .block_reason = "blocklist", + .response_time_us = 4200, + .cache_hit = true, + .upstream = "https://dns.example/dns-query", + }, + .{ + .timestamp = 20, + .domain = "quiet.example", + .client_ip = "hidden", + .qtype = null, + .blocked = false, + .block_reason = null, + .response_time_us = null, + .cache_hit = null, + .upstream = null, + }, + }); + + const rows = try selectQueries(&database, arena_state.allocator(), .{}); + try testing.expectEqual(@as(usize, 2), rows.items.len); + + const newest = rows.items[0]; + try testing.expectEqual(@as(i64, 2), newest.id); + try testing.expectEqual(@as(i64, 20), newest.ts); + try testing.expectEqualStrings("quiet.example", newest.domain); + try testing.expectEqualStrings("hidden", newest.client_ip); + try testing.expectEqual(@as(?u16, null), newest.qtype); + try testing.expect(!newest.blocked); + // A NULL text column reads as the empty string, by documented convention. + try testing.expectEqualStrings("", newest.block_reason); + try testing.expectEqual(@as(?i64, null), newest.response_time_us); + try testing.expectEqual(@as(?bool, null), newest.cache_hit); + try testing.expectEqualStrings("", newest.upstream); + + const oldest = rows.items[1]; + try testing.expectEqual(@as(i64, 1), oldest.id); + try testing.expectEqual(@as(i64, 10), oldest.ts); + try testing.expectEqualStrings("ads.example.net", oldest.domain); + try testing.expectEqualStrings("192.0.2.10", oldest.client_ip); + try testing.expectEqual(@as(?u16, 28), oldest.qtype); + try testing.expect(oldest.blocked); + try testing.expectEqualStrings("blocklist", oldest.block_reason); + try testing.expectEqual(@as(?i64, 4200), oldest.response_time_us); + try testing.expectEqual(@as(?bool, true), oldest.cache_hit); + try testing.expectEqualStrings("https://dns.example/dns-query", oldest.upstream); +} + +test "selectQueries honours the limit and caps it at max_limit" { + var database = try openLog(); + defer database.close(); + var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var rows: [1005]Row = undefined; + for (&rows, 0..) |*row, i| row.* = plainRow(@intCast(i), "example.com"); + try seed(&database, &rows); + + const few = try selectQueries(&database, arena, .{ .limit = 3 }); + try testing.expectEqual(@as(usize, 3), few.items.len); + + // Asked for more than the cap, and for more rows than the cap, so the cap + // is what bounds the answer rather than the table. + const capped = try selectQueries(&database, arena, .{ .limit = 5000 }); + try testing.expectEqual(@as(usize, max_limit), capped.items.len); + + const none = try selectQueries(&database, arena, .{ .limit = 0 }); + try testing.expectEqual(@as(usize, 0), none.items.len); +} + +test "keyset paging walks every row exactly once across the page boundaries" { + var database = try openLog(); + defer database.close(); + var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var seeded: [7]Row = undefined; + for (&seeded, 0..) |*row, i| row.* = plainRow(@intCast(i), "example.com"); + try seed(&database, &seeded); + + var seen: std.ArrayList(i64) = .empty; + defer seen.deinit(testing.allocator); + + var before: ?i64 = null; + var pages: usize = 0; + while (pages < 10) : (pages += 1) { + const page = try selectQueries(&database, arena, .{ .limit = 3, .before = before }); + if (page.items.len == 0) break; + for (page.items) |row| try seen.append(testing.allocator, row.id); + before = page.items[page.items.len - 1].id; + } + + // Two full pages and one short page; the fourth call returns nothing and + // breaks before the counter, which is how the walk knows it is done. + try testing.expectEqual(@as(usize, 3), pages); + try testing.expectEqualSlices(i64, &.{ 7, 6, 5, 4, 3, 2, 1 }, seen.items); +} + +test "each filter narrows the result on its own" { + var database = try openLog(); + defer database.close(); + var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var blocked_row = plainRow(200, "ads.example.net"); + blocked_row.client_ip = "192.0.2.20"; + blocked_row.blocked = true; + blocked_row.block_reason = "blocklist"; + try seed(&database, &.{ + plainRow(100, "one.example.com"), + blocked_row, + plainRow(300, "two.example.com"), + }); + + var buf: [8]i64 = undefined; + + const by_domain = try selectQueries(&database, arena, .{ .domain_substring = "example.com" }); + try testing.expectEqualSlices(i64, &.{ 3, 1 }, ids(by_domain.items, &buf)); + + const by_client = try selectQueries(&database, arena, .{ .client = "192.0.2.20" }); + try testing.expectEqualSlices(i64, &.{2}, ids(by_client.items, &buf)); + + // An exact match, not a prefix: the seeded clients share the first octets. + const no_client = try selectQueries(&database, arena, .{ .client = "192.0.2" }); + try testing.expectEqual(@as(usize, 0), no_client.items.len); + + const only_blocked = try selectQueries(&database, arena, .{ .blocked = true }); + try testing.expectEqualSlices(i64, &.{2}, ids(only_blocked.items, &buf)); + + const only_allowed = try selectQueries(&database, arena, .{ .blocked = false }); + try testing.expectEqualSlices(i64, &.{ 3, 1 }, ids(only_allowed.items, &buf)); + + // Every filter at once, all satisfied by the one blocked row. + const combined = try selectQueries(&database, arena, .{ + .limit = 10, + .before = 3, + .domain_substring = "ads", + .client = "192.0.2.20", + .blocked = true, + .since = 200, + .until = 300, + }); + try testing.expectEqualSlices(i64, &.{2}, ids(combined.items, &buf)); +} + +test "since is inclusive, until is exclusive, and an empty range selects nothing" { + var database = try openLog(); + defer database.close(); + var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + try seed(&database, &.{ + plainRow(100, "a.example"), + plainRow(200, "b.example"), + plainRow(300, "c.example"), + }); + + var buf: [8]i64 = undefined; + + const window = try selectQueries(&database, arena, .{ .since = 100, .until = 300 }); + try testing.expectEqualSlices(i64, &.{ 2, 1 }, ids(window.items, &buf)); + + const after = try selectQueries(&database, arena, .{ .since = 300 }); + try testing.expectEqualSlices(i64, &.{3}, ids(after.items, &buf)); + + const empty = try selectQueries(&database, arena, .{ .since = 300, .until = 300 }); + try testing.expectEqual(@as(usize, 0), empty.items.len); + + const beyond = try selectQueries(&database, arena, .{ .since = 1000 }); + try testing.expectEqual(@as(usize, 0), beyond.items.len); +} + +test "a domain substring matches % and _ as literal characters" { + var database = try openLog(); + defer database.close(); + var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + try seed(&database, &.{ + plainRow(10, "a_b.example"), + plainRow(20, "axb.example"), + plainRow(30, "a%b.example"), + plainRow(40, "azzb.example"), + plainRow(50, "back\\slash.example"), + }); + + var buf: [8]i64 = undefined; + + // Unescaped, `_` is LIKE's single-character wildcard and would also match + // "axb"; escaped, it matches only the underscore. + const underscore = try selectQueries(&database, arena, .{ .domain_substring = "a_b" }); + try testing.expectEqualSlices(i64, &.{1}, ids(underscore.items, &buf)); + + // Unescaped, `%` would match everything from "a" to "b", so "azzb" too. + const percent = try selectQueries(&database, arena, .{ .domain_substring = "a%b" }); + try testing.expectEqualSlices(i64, &.{3}, ids(percent.items, &buf)); + + // The escape character escapes itself, so it is searchable as well. + const backslash = try selectQueries(&database, arena, .{ .domain_substring = "k\\s" }); + try testing.expectEqualSlices(i64, &.{5}, ids(backslash.items, &buf)); + + // An empty needle is `%%`, which matches every row rather than none. + const all = try selectQueries(&database, arena, .{ .domain_substring = "" }); + try testing.expectEqual(@as(usize, 5), all.items.len); +} + +test "statsTotals aggregates the window and averages only the timed rows" { + var database = try openLog(); + defer database.close(); + + var timed = plainRow(100, "a.example"); + timed.response_time_us = 100; + var blocked_row = plainRow(150, "ads.example"); + blocked_row.blocked = true; + blocked_row.block_reason = "blocklist"; + blocked_row.response_time_us = 200; + var cached = plainRow(199, "b.example"); + cached.client_ip = "192.0.2.99"; + cached.cache_hit = true; + cached.response_time_us = null; + try seed(&database, &.{ timed, blocked_row, cached, plainRow(200, "outside.example") }); + + const totals = try statsTotals(&database, 100, 200); + try testing.expectEqual(@as(u64, 3), totals.queries); + try testing.expectEqual(@as(u64, 1), totals.blocked); + try testing.expectEqual(@as(u64, 1), totals.cached); + try testing.expectEqual(@as(u64, 2), totals.distinct_clients); + // (100 + 200) / 2 — the untimed row is not in the divisor. + try testing.expectEqual(@as(?i64, 150), totals.avg_response_time_us); +} + +test "statsTotals over an empty window is zeros with a null average" { + var database = try openLog(); + defer database.close(); + try seed(&database, &.{plainRow(100, "a.example")}); + + for ([_][2]i64{ .{ 500, 600 }, .{ 100, 100 } }) |window| { + const totals = try statsTotals(&database, window[0], window[1]); + try testing.expectEqual(@as(u64, 0), totals.queries); + try testing.expectEqual(@as(u64, 0), totals.blocked); + try testing.expectEqual(@as(u64, 0), totals.cached); + try testing.expectEqual(@as(u64, 0), totals.distinct_clients); + try testing.expectEqual(@as(?i64, null), totals.avg_response_time_us); + } +} + +test "timeseries writes every bucket, including the ones with no rows" { + var database = try openLog(); + defer database.close(); + + var blocked_row = plainRow(1020, "ads.example"); + blocked_row.blocked = true; + blocked_row.block_reason = "blocklist"; + var cached = plainRow(1035, "b.example"); + cached.cache_hit = true; + try seed(&database, &.{ + plainRow(995, "before.example"), + plainRow(1000, "a.example"), + plainRow(1009, "a.example"), + blocked_row, + cached, + plainRow(1040, "after.example"), + }); + + var buckets: [4]Bucket = undefined; + try testing.expectEqual(@as(usize, 4), try timeseries(&database, 1000, 10, &buckets)); + + // The row at 995 is before the window and the row at 1040 is past its end; + // neither lands in a bucket. + try testing.expectEqualSlices(Bucket, &.{ + .{ .ts = 1000, .queries = 2, .blocked = 0, .cached = 0 }, + .{ .ts = 1010, .queries = 0, .blocked = 0, .cached = 0 }, + .{ .ts = 1020, .queries = 1, .blocked = 1, .cached = 0 }, + .{ .ts = 1030, .queries = 1, .blocked = 0, .cached = 1 }, + }, &buckets); +} + +test "timeseries over an empty table still writes the whole axis" { + var database = try openLog(); + defer database.close(); + + var buckets: [3]Bucket = undefined; + try testing.expectEqual(@as(usize, 3), try timeseries(&database, 0, 60, &buckets)); + try testing.expectEqualSlices(Bucket, &.{ + .{ .ts = 0, .queries = 0, .blocked = 0, .cached = 0 }, + .{ .ts = 60, .queries = 0, .blocked = 0, .cached = 0 }, + .{ .ts = 120, .queries = 0, .blocked = 0, .cached = 0 }, + }, &buckets); +} + +test "timeseries rejects a zero-width bucket and accepts an empty slice" { + var database = try openLog(); + defer database.close(); + + var buckets: [2]Bucket = undefined; + try testing.expectError(error.Misuse, timeseries(&database, 0, 0, &buckets)); + + var none: [0]Bucket = undefined; + try testing.expectEqual(@as(usize, 0), try timeseries(&database, 0, 0, &none)); +} + +test "timeseries reports a window that does not fit an i64 rather than wrapping" { + var database = try openLog(); + defer database.close(); + + var buckets: [4]Bucket = undefined; + try testing.expectError( + error.Misuse, + timeseries(&database, std.math.maxInt(i64) - 1, 3600, &buckets), + ); +} + +test "the built SQL never carries a filter value and fits its buffer" { + var sql: Sql = .{}; + sql.put(select_head); + sql.predicate(where_before); + sql.predicate(where_domain); + sql.predicate(where_client); + sql.predicate(where_blocked); + sql.predicate(where_since); + sql.predicate(where_until); + sql.put(select_tail); + + // Every predicate present is the longest reachable statement. + try testing.expect(sql.len <= Sql.capacity); + try testing.expectEqual(@as(usize, 1), std.mem.count(u8, sql.text(), " WHERE")); + try testing.expectEqual(@as(usize, 5), std.mem.count(u8, sql.text(), " AND")); + // Six filters plus the LIMIT, each a bare parameter. + try testing.expectEqual(@as(usize, 7), std.mem.count(u8, sql.text(), "?")); +} + +test "likePattern wraps the needle and neutralises every metacharacter" { + var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + try testing.expectEqualStrings("%plain%", try likePattern(arena, "plain")); + try testing.expectEqualStrings("%a\\_b%", try likePattern(arena, "a_b")); + try testing.expectEqualStrings("%a\\%b%", try likePattern(arena, "a%b")); + try testing.expectEqualStrings("%a\\\\b%", try likePattern(arena, "a\\b")); + try testing.expectEqualStrings("%%", try likePattern(arena, "")); +} diff --git a/src/storage/repositories/rules_repo.zig b/src/storage/repositories/rules_repo.zig index cf1f889..5ce39e4 100644 --- a/src/storage/repositories/rules_repo.zig +++ b/src/storage/repositories/rules_repo.zig @@ -12,7 +12,9 @@ //! order as a whole is: `import` inserts the rules in export order, so the new //! ids ascend in exactly the order this statement produced. //! -//! Only list / insert / deleteAll / count exist. +//! The import path is list / insert / deleteAll / count. Phase 8's REST surface +//! is the second half of this file: it speaks row ids, because that is what an +//! `/api/rules/{id}` request names. const std = @import("std"); const Allocator = std.mem.Allocator; @@ -22,6 +24,7 @@ const migrations = @import("../migrations.zig"); const model = @import("../../config/model.zig"); const context = @import("context.zig"); const groups_repo = @import("groups_repo.zig"); +const crud = @import("crud.zig"); const IdMap = context.IdMap; const InsertContext = context.InsertContext; @@ -89,6 +92,145 @@ pub fn countRules(database: *db.Db) db.Error!i64 { return database.queryInt("SELECT count(*) FROM rules"); } +// --------------------------------------------------------------------------- +// REST surface (milestone 8) +// --------------------------------------------------------------------------- +// +// The read shape carries both `group_id` and the group name: the UI groups the +// rules it lists, and the client that edits one sends an id back. The write +// shape carries only the id, so a group that does not exist surfaces as the +// foreign-key violation it is instead of a lookup miss. + +pub const RuleRow = struct { + id: i64, + group_id: i64, + group: []const u8, + pattern: []const u8, + kind: model.RuleKind, + action: model.RuleAction, + created_at: i64, +}; + +pub const RuleInput = struct { + group_id: i64, + pattern: []const u8, + kind: model.RuleKind, + action: model.RuleAction, +}; + +const list_rule_rows_sql = + \\SELECT r.id, r.group_id, g.name, r.pattern, r.kind, r.action, r.created_at FROM rules r + \\ JOIN groups g ON g.id = r.group_id + \\ ORDER BY g.name, r.kind, r.action, r.pattern, r.id +; + +const get_rule_sql = + \\SELECT r.id, r.group_id, g.name, r.pattern, r.kind, r.action, r.created_at FROM rules r + \\ JOIN groups g ON g.id = r.group_id + \\ WHERE r.id = ?1 +; + +/// Same order as `listRules`; every string is a heap copy owned by `gpa`. +pub fn listRuleRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(RuleRow) { + var stmt = try database.prepare(list_rule_rows_sql); + defer stmt.deinit(); + + var out: std.ArrayList(RuleRow) = .empty; + errdefer out.deinit(gpa); + errdefer freeRuleRows(gpa, out.items); + + while (try stmt.step()) { + const row = try readRuleRow(&stmt, gpa); + errdefer freeRuleRow(gpa, row); + try out.append(gpa, row); + } + return out; +} + +pub fn freeRuleRow(gpa: Allocator, row: RuleRow) void { + gpa.free(row.group); + gpa.free(row.pattern); +} + +pub fn freeRuleRows(gpa: Allocator, items: []const RuleRow) void { + for (items) |item| freeRuleRow(gpa, item); +} + +pub fn getRule(database: *db.Db, gpa: Allocator, id: i64) db.Error!?RuleRow { + var stmt = try database.prepare(get_rule_sql); + defer stmt.deinit(); + try stmt.bindInt(1, id); + if (!try stmt.step()) return null; + return try readRuleRow(&stmt, gpa); +} + +fn readRuleRow(stmt: *db.Stmt, gpa: Allocator) db.Error!RuleRow { + // The DDL's CHECK constraints make both decodes total for any row nxdns + // wrote; `error.Mismatch` covers a row that something else wrote. + const kind = model.RuleKind.fromDb(stmt.columnText(4)) orelse return error.Mismatch; + const action = model.RuleAction.fromDb(stmt.columnText(5)) orelse return error.Mismatch; + const group = try stmt.columnTextAlloc(gpa, 2); + errdefer gpa.free(group); + const pattern = try stmt.columnTextAlloc(gpa, 3); + errdefer gpa.free(pattern); + return .{ + .id = stmt.columnInt(0), + .group_id = stmt.columnInt(1), + .group = group, + .pattern = pattern, + .kind = kind, + .action = action, + .created_at = stmt.columnInt(6), + }; +} + +/// `now_s` is unix epoch seconds, from `std.Io.Clock.real`; it becomes +/// `created_at`, the column that dates a rule for the operator. +/// +/// `error.Constraint`: `group_id` names no group. `rules` has no UNIQUE +/// constraint, so a rule identical to one already stored is accepted — the +/// table has always allowed that. +pub fn insertRuleRow(database: *db.Db, item: RuleInput, now_s: i64) db.Error!i64 { + var stmt = try database.prepare(insert_sql); + defer stmt.deinit(); + try stmt.bindInt(1, item.group_id); + try stmt.bindText(2, item.pattern); + try stmt.bindText(3, item.kind.toDb()); + try stmt.bindText(4, item.action.toDb()); + try stmt.bindInt(5, now_s); + try stmt.exec(); + return database.lastInsertRowid(); +} + +const update_rule_sql = + \\UPDATE rules SET group_id = ?2, pattern = ?3, kind = ?4, action = ?5 WHERE id = ?1 +; + +/// `created_at` is when the rule was written, not when it was last touched, so +/// an edit leaves it alone. +/// +/// `error.NotFound`: no rule holds `id`. `error.Constraint`: `group_id` names no +/// group. +pub fn updateRule(database: *db.Db, id: i64, item: RuleInput) db.Error!void { + var stmt = try database.prepare(update_rule_sql); + defer stmt.deinit(); + try stmt.bindInt(1, id); + try stmt.bindInt(2, item.group_id); + try stmt.bindText(3, item.pattern); + try stmt.bindText(4, item.kind.toDb()); + try stmt.bindText(5, item.action.toDb()); + return crud.execStrict(database, &stmt); +} + +/// `error.NotFound`: no rule holds `id`. Nothing references `rules`, so a delete +/// cannot violate a constraint. +pub fn deleteRule(database: *db.Db, id: i64) db.Error!void { + var stmt = try database.prepare("DELETE FROM rules WHERE id = ?1"); + defer stmt.deinit(); + try stmt.bindInt(1, id); + return crud.execStrict(database, &stmt); +} + // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- @@ -292,3 +434,100 @@ test "listRules is leak-safe under allocation failure" { defer ids.deinit(testing.allocator); try testing.checkAllAllocationFailures(testing.allocator, listRulesUnderFailure, .{&ids}); } + +// --- REST surface ---------------------------------------------------------- + +test "a rule round-trips through insert, get, list, update and delete" { + var database = try openMigrated(); + defer database.close(); + const kids = try groups_repo.insertGroupRow(&database, .{ .name = "kids" }); + + const id = try insertRuleRow(&database, .{ + .group_id = 1, + .pattern = "tracker.example", + .kind = .exact, + .action = .block, + }, 1700000000); + + const fetched = (try getRule(&database, testing.allocator, id)).?; + defer freeRuleRow(testing.allocator, fetched); + try testing.expectEqual(id, fetched.id); + try testing.expectEqual(@as(i64, 1), fetched.group_id); + try testing.expectEqualStrings("default", fetched.group); + try testing.expectEqualStrings("tracker.example", fetched.pattern); + try testing.expectEqual(model.RuleKind.exact, fetched.kind); + try testing.expectEqual(model.RuleAction.block, fetched.action); + try testing.expectEqual(@as(i64, 1700000000), fetched.created_at); + + try updateRule(&database, id, .{ + .group_id = kids, + .pattern = "*.ads.example", + .kind = .wildcard, + .action = .allow, + }); + const updated = (try getRule(&database, testing.allocator, id)).?; + defer freeRuleRow(testing.allocator, updated); + try testing.expectEqual(kids, updated.group_id); + try testing.expectEqualStrings("kids", updated.group); + try testing.expectEqualStrings("*.ads.example", updated.pattern); + try testing.expectEqual(model.RuleKind.wildcard, updated.kind); + try testing.expectEqual(model.RuleAction.allow, updated.action); + // An edit is not a creation, so the date stands. + try testing.expectEqual(@as(i64, 1700000000), updated.created_at); + + var rows = try listRuleRows(&database, testing.allocator); + defer rows.deinit(testing.allocator); + defer freeRuleRows(testing.allocator, rows.items); + try testing.expectEqual(@as(usize, 1), rows.items.len); + try testing.expectEqual(id, rows.items[0].id); + + try deleteRule(&database, id); + try testing.expectEqual(@as(?RuleRow, null), try getRule(&database, testing.allocator, id)); + try testing.expectEqual(@as(i64, 0), try countRules(&database)); +} + +test "rule update and delete report NotFound for an id no row holds" { + var database = try openMigrated(); + defer database.close(); + + const item: RuleInput = .{ .group_id = 1, .pattern = "x.example", .kind = .exact, .action = .block }; + try testing.expectError(error.NotFound, updateRule(&database, 404, item)); + try testing.expectError(error.NotFound, deleteRule(&database, 404)); + try testing.expectEqual(@as(?RuleRow, null), try getRule(&database, testing.allocator, 404)); +} + +test "a rule in a group that does not exist surfaces as error.Constraint" { + var database = try openMigrated(); + defer database.close(); + + const item: RuleInput = .{ .group_id = 404, .pattern = "x.example", .kind = .exact, .action = .block }; + try testing.expectError(error.Constraint, insertRuleRow(&database, item, 1)); + + const id = try insertRuleRow(&database, .{ + .group_id = 1, + .pattern = "x.example", + .kind = .exact, + .action = .block, + }, 1); + try testing.expectError(error.Constraint, updateRule(&database, id, item)); + try testing.expectEqual(@as(i64, 1), try countRules(&database)); +} + +fn ruleRowsUnderFailure(gpa: Allocator, ids: *const IdMap) !void { + var database = try openMigrated(); + defer database.close(); + try seedRules(&database, ids); + + var rows = try listRuleRows(&database, gpa); + defer rows.deinit(gpa); + defer freeRuleRows(gpa, rows.items); + + const one = (try getRule(&database, gpa, rows.items[0].id)).?; + defer freeRuleRow(gpa, one); +} + +test "the rule read surface is leak-safe under allocation failure" { + var ids = try seedGroupIds(); + defer ids.deinit(testing.allocator); + try testing.checkAllAllocationFailures(testing.allocator, ruleRowsUnderFailure, .{&ids}); +} diff --git a/src/storage/repositories/settings_repo.zig b/src/storage/repositories/settings_repo.zig index 5c96c88..cf8e511 100644 --- a/src/storage/repositories/settings_repo.zig +++ b/src/storage/repositories/settings_repo.zig @@ -4,7 +4,9 @@ //! `model.fromSettings` speak, so the scalar sections cross the storage boundary //! without a second shape. //! -//! Only list / insert / deleteAll / count exist. +//! The import path is list / insert / deleteAll / count; `putSetting` is Phase +//! 8's single-key write. `settings` has no row ids — the key is the identity — +//! so it gains no by-id surface. const std = @import("std"); const Allocator = std.mem.Allocator; @@ -64,6 +66,32 @@ pub fn countSettings(database: *db.Db) db.Error!i64 { return database.queryInt("SELECT count(*) FROM settings"); } +// --------------------------------------------------------------------------- +// REST surface (milestone 8) +// --------------------------------------------------------------------------- + +const put_setting_sql = + \\INSERT INTO settings (key, value) VALUES (?1, ?2) + \\ON CONFLICT(key) DO UPDATE SET value = excluded.value +; + +/// Writes one key, whether or not it is already stored. +/// +/// `PUT /api/settings` is a partial update over a table whose rows the import +/// path writes once and never revisits, so a plain `INSERT` would fail on every +/// key the config already carries and a plain `UPDATE` would drop every key it +/// does not. The conflict target is `settings.key`, the table's PRIMARY KEY. +/// +/// No constraint can fire: the table has one key column and one `NOT NULL` +/// value, and both are bound. +pub fn putSetting(database: *db.Db, key: []const u8, value: []const u8) db.Error!void { + var stmt = try database.prepare(put_setting_sql); + defer stmt.deinit(); + try stmt.bindText(1, key); + try stmt.bindText(2, value); + try stmt.exec(); +} + // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- @@ -143,3 +171,41 @@ fn listSettingsUnderFailure(gpa: Allocator) !void { test "listSettings is leak-safe under allocation failure" { try testing.checkAllAllocationFailures(testing.allocator, listSettingsUnderFailure, .{}); } + +// --- REST surface ---------------------------------------------------------- + +test "putSetting writes a key that is absent and overwrites one that is present" { + var database = try openMigrated(); + defer database.close(); + try seedSettings(&database); + + try putSetting(&database, "web.port", "9090"); + try putSetting(&database, "cache.max_entries", "20000"); + + var items = try listSettings(&database, testing.allocator); + defer items.deinit(testing.allocator); + defer freeSettings(testing.allocator, items.items); + + try testing.expectEqual(@as(usize, 4), items.items.len); + try testing.expectEqualStrings("cache.max_entries", items.items[0].key); + try testing.expectEqualStrings("20000", items.items[0].value); + try testing.expectEqualStrings("web.port", items.items[3].key); + try testing.expectEqualStrings("9090", items.items[3].value); + // The keys it did not name are untouched. + try testing.expectEqualStrings("dns.port", items.items[1].key); + try testing.expectEqualStrings("53", items.items[1].value); +} + +test "putSetting is idempotent and leaves the row count alone" { + var database = try openMigrated(); + defer database.close(); + + try putSetting(&database, "web.password_hash", "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA"); + try putSetting(&database, "web.password_hash", "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA"); + try testing.expectEqual(@as(i64, 1), try countSettings(&database)); + + var items = try listSettings(&database, testing.allocator); + defer items.deinit(testing.allocator); + defer freeSettings(testing.allocator, items.items); + try testing.expectEqualStrings("$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA", items.items[0].value); +} diff --git a/src/storage/repositories/sources_repo.zig b/src/storage/repositories/sources_repo.zig index b39dea5..fec3730 100644 --- a/src/storage/repositories/sources_repo.zig +++ b/src/storage/repositories/sources_repo.zig @@ -5,7 +5,8 @@ //! facts a running server produces; an insert leaves them at their column //! defaults so two exports taken minutes apart stay identical. //! -//! Only list / insert / deleteAll / count exist. +//! The import path is list / insert / deleteAll / count; the runtime columns and +//! Phase 8's REST surface follow it, both keyed by row id. const std = @import("std"); const Allocator = std.mem.Allocator; @@ -14,6 +15,7 @@ const db = @import("../db.zig"); const migrations = @import("../migrations.zig"); const model = @import("../../config/model.zig"); const context = @import("context.zig"); +const crud = @import("crud.zig"); const InsertContext = context.InsertContext; @@ -91,6 +93,9 @@ pub const SourceRow = struct { url: []const u8, name: []const u8, enabled: bool, + /// Defaulted because the blocklist manager builds `SourceRow` values from + /// the refresh columns alone; the REST layer is what reads this one. + is_suggested: bool = false, last_updated: ?i64, domain_count: i64, wildcard_count: i64, @@ -107,12 +112,15 @@ pub const SourceStats = struct { checksum: []const u8, }; -const list_rows_sql = +const row_columns_sql = \\SELECT id, url, name, enabled, last_updated, - \\ domain_count, wildcard_count, skipped_regex_count, checksum - \\ FROM blocklist_sources ORDER BY url + \\ domain_count, wildcard_count, skipped_regex_count, checksum, + \\ is_suggested + \\ FROM blocklist_sources ; +const list_rows_sql = row_columns_sql ++ " ORDER BY url"; + /// Every source with its row id and its runtime columns, in the same `url` /// order `listBlocklistSources` uses. Every string is a heap copy owned by /// `gpa`; free the whole list with `freeSourceRows` and then `deinit` the list. @@ -127,33 +135,42 @@ pub fn listSourceRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(S errdefer freeSourceRows(gpa, out.items); while (try stmt.step()) { - const url = try stmt.columnTextAlloc(gpa, 1); - errdefer gpa.free(url); - const name = try stmt.columnTextAlloc(gpa, 2); - errdefer gpa.free(name); - const checksum = try stmt.columnTextAllocOrNull(gpa, 8); - errdefer if (checksum) |value| gpa.free(value); - try out.append(gpa, .{ - .id = stmt.columnInt(0), - .url = url, - .name = name, - .enabled = stmt.columnBool(3), - .last_updated = if (stmt.isNull(4)) null else stmt.columnInt(4), - .domain_count = stmt.columnInt(5), - .wildcard_count = stmt.columnInt(6), - .skipped_regex_count = stmt.columnInt(7), - .checksum = checksum, - }); + const row = try readSourceRow(&stmt, gpa); + errdefer freeSourceRow(gpa, row); + try out.append(gpa, row); } return out; } +fn readSourceRow(stmt: *db.Stmt, gpa: Allocator) db.Error!SourceRow { + const url = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(url); + const name = try stmt.columnTextAlloc(gpa, 2); + errdefer gpa.free(name); + const checksum = try stmt.columnTextAllocOrNull(gpa, 8); + errdefer if (checksum) |value| gpa.free(value); + return .{ + .id = stmt.columnInt(0), + .url = url, + .name = name, + .enabled = stmt.columnBool(3), + .is_suggested = stmt.columnBool(9), + .last_updated = if (stmt.isNull(4)) null else stmt.columnInt(4), + .domain_count = stmt.columnInt(5), + .wildcard_count = stmt.columnInt(6), + .skipped_regex_count = stmt.columnInt(7), + .checksum = checksum, + }; +} + +pub fn freeSourceRow(gpa: Allocator, row: SourceRow) void { + gpa.free(row.url); + gpa.free(row.name); + if (row.checksum) |value| gpa.free(value); +} + pub fn freeSourceRows(gpa: Allocator, items: []const SourceRow) void { - for (items) |item| { - gpa.free(item.url); - gpa.free(item.name); - if (item.checksum) |value| gpa.free(value); - } + for (items) |item| freeSourceRow(gpa, item); } const update_stats_sql = @@ -177,6 +194,71 @@ pub fn updateSourceStats(database: *db.Db, id: i64, stats: SourceStats) db.Error try stmt.exec(); } +// --------------------------------------------------------------------------- +// REST surface (milestone 8) +// --------------------------------------------------------------------------- +// +// `/api/blocklists` is this table. The read shape is `SourceRow` above — the +// UI wants the counters next to the configuration — and the write shape is +// `model.BlocklistSource`, whose four fields are the four columns an operator +// may set. + +pub fn getSource(database: *db.Db, gpa: Allocator, id: i64) db.Error!?SourceRow { + var stmt = try database.prepare(row_columns_sql ++ " WHERE id = ?1"); + defer stmt.deinit(); + try stmt.bindInt(1, id); + if (!try stmt.step()) return null; + return try readSourceRow(&stmt, gpa); +} + +/// The runtime columns stay at their defaults, so a source added through the +/// API looks exactly like an imported one until the first refresh. +/// +/// `error.Constraint`: `blocklist_sources.url` is UNIQUE. +pub fn insertSourceRow(database: *db.Db, item: model.BlocklistSource) db.Error!i64 { + var stmt = try database.prepare(insert_sql); + defer stmt.deinit(); + try stmt.bindText(1, item.url); + try stmt.bindText(2, item.name); + try stmt.bindBool(3, item.enabled); + try stmt.bindBool(4, item.is_suggested); + try stmt.exec(); + return database.lastInsertRowid(); +} + +const update_source_sql = + \\UPDATE blocklist_sources + \\ SET url = ?2, name = ?3, enabled = ?4, is_suggested = ?5 + \\ WHERE id = ?1 +; + +/// Writes the four configuration columns. The runtime columns are the refresh +/// path's and stay as they are — even when `url` changes, because the next +/// refresh compares checksums and replaces them anyway. +/// +/// `error.NotFound`: no source holds `id`. `error.Constraint`: +/// `blocklist_sources.url` is UNIQUE. +pub fn updateSource(database: *db.Db, id: i64, item: model.BlocklistSource) db.Error!void { + var stmt = try database.prepare(update_source_sql); + defer stmt.deinit(); + try stmt.bindInt(1, id); + try stmt.bindText(2, item.url); + try stmt.bindText(3, item.name); + try stmt.bindBool(4, item.enabled); + try stmt.bindBool(5, item.is_suggested); + return crud.execStrict(database, &stmt); +} + +/// `error.NotFound`: no source holds `id`. `group_sources` references it +/// `ON DELETE CASCADE`, so every group's assignment loses it silently and no +/// constraint can fire. +pub fn deleteSource(database: *db.Db, id: i64) db.Error!void { + var stmt = try database.prepare("DELETE FROM blocklist_sources WHERE id = ?1"); + defer stmt.deinit(); + try stmt.bindInt(1, id); + return crud.execStrict(database, &stmt); +} + // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- @@ -361,3 +443,126 @@ fn listSourceRowsUnderFailure(gpa: Allocator) !void { test "listSourceRows is leak-safe under allocation failure" { try testing.checkAllAllocationFailures(testing.allocator, listSourceRowsUnderFailure, .{}); } + +// --- REST surface ---------------------------------------------------------- + +test "a blocklist source round-trips through insert, get, list, update and delete" { + var database = try openMigrated(); + defer database.close(); + + const id = try insertSourceRow(&database, .{ + .url = "https://lists.example/hosts.txt", + .name = "Example", + .is_suggested = true, + }); + + const fetched = (try getSource(&database, testing.allocator, id)).?; + defer freeSourceRow(testing.allocator, fetched); + try testing.expectEqual(id, fetched.id); + try testing.expectEqualStrings("https://lists.example/hosts.txt", fetched.url); + try testing.expectEqualStrings("Example", fetched.name); + try testing.expect(fetched.enabled); + try testing.expect(fetched.is_suggested); + try testing.expectEqual(@as(?i64, null), fetched.last_updated); + + try updateSource(&database, id, .{ + .url = "https://lists.example/hosts.txt", + .name = "Example list", + .enabled = false, + }); + const updated = (try getSource(&database, testing.allocator, id)).?; + defer freeSourceRow(testing.allocator, updated); + try testing.expectEqualStrings("Example list", updated.name); + try testing.expect(!updated.enabled); + try testing.expect(!updated.is_suggested); + + try deleteSource(&database, id); + try testing.expectEqual(@as(?SourceRow, null), try getSource(&database, testing.allocator, id)); + try testing.expectEqual(@as(i64, 0), try countBlocklistSources(&database)); +} + +test "updateSource leaves the runtime columns where the refresh path left them" { + var database = try openMigrated(); + defer database.close(); + + const id = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" }); + try updateSourceStats(&database, id, .{ + .last_updated = 1_700_000_000, + .domain_count = 12, + .wildcard_count = 3, + .skipped_regex_count = 1, + .checksum = "c" ** 64, + }); + + try updateSource(&database, id, .{ .url = "https://lists.example/b.txt", .name = "B" }); + + const row = (try getSource(&database, testing.allocator, id)).?; + defer freeSourceRow(testing.allocator, row); + try testing.expectEqualStrings("https://lists.example/b.txt", row.url); + try testing.expectEqual(@as(?i64, 1_700_000_000), row.last_updated); + try testing.expectEqual(@as(i64, 12), row.domain_count); + try testing.expectEqualStrings("c" ** 64, row.checksum.?); +} + +test "source update and delete report NotFound for an id no row holds" { + var database = try openMigrated(); + defer database.close(); + + try testing.expectError( + error.NotFound, + updateSource(&database, 404, .{ .url = "https://lists.example/a.txt", .name = "A" }), + ); + try testing.expectError(error.NotFound, deleteSource(&database, 404)); + try testing.expectEqual(@as(?SourceRow, null), try getSource(&database, testing.allocator, 404)); +} + +test "a duplicate source url surfaces as error.Constraint on insert and on update" { + var database = try openMigrated(); + defer database.close(); + + _ = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" }); + const other = try insertSourceRow(&database, .{ .url = "https://lists.example/b.txt", .name = "B" }); + + try testing.expectError( + error.Constraint, + insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "again" }), + ); + try testing.expectError( + error.Constraint, + updateSource(&database, other, .{ .url = "https://lists.example/a.txt", .name = "B" }), + ); + try testing.expectEqual(@as(i64, 2), try countBlocklistSources(&database)); +} + +test "deleting a source drops it from every group assignment" { + var database = try openMigrated(); + defer database.close(); + + const id = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" }); + const keep = try insertSourceRow(&database, .{ .url = "https://lists.example/b.txt", .name = "B" }); + try database.exec("INSERT INTO group_sources (group_id, source_id) VALUES (1, 1), (1, 2);"); + + try deleteSource(&database, id); + try testing.expectEqual(@as(i64, 1), try countGroupSources(&database)); + try testing.expectEqual( + keep, + try database.queryInt("SELECT source_id FROM group_sources"), + ); +} + +fn countGroupSources(database: *db.Db) db.Error!i64 { + return database.queryInt("SELECT count(*) FROM group_sources"); +} + +fn sourceRowUnderFailure(gpa: Allocator) !void { + var database = try openMigrated(); + defer database.close(); + try seedSources(&database); + + const one = (try getSource(&database, gpa, 1)).?; + defer freeSourceRow(gpa, one); +} + +test "getSource is leak-safe under allocation failure" { + try testing.checkAllAllocationFailures(testing.allocator, sourceRowUnderFailure, .{}); +} diff --git a/src/storage/repositories/upstreams_repo.zig b/src/storage/repositories/upstreams_repo.zig index 1549ae3..6a7d940 100644 --- a/src/storage/repositories/upstreams_repo.zig +++ b/src/storage/repositories/upstreams_repo.zig @@ -4,7 +4,9 @@ //! meaningful order — it matches what `Pool.init` expects — and `url` breaks //! ties uniquely, which is what makes an export byte-stable. //! -//! Only list / insert / deleteAll / count exist. +//! The import path is list / insert / deleteAll / count. Phase 8's REST surface +//! is the second half of this file: it speaks row ids, because that is what an +//! `/api/upstreams/{id}` request names. const std = @import("std"); const Allocator = std.mem.Allocator; @@ -13,6 +15,7 @@ const db = @import("../db.zig"); const migrations = @import("../migrations.zig"); const model = @import("../../config/model.zig"); const context = @import("context.zig"); +const crud = @import("crud.zig"); const InsertContext = context.InsertContext; @@ -73,6 +76,118 @@ pub fn countUpstreams(database: *db.Db) db.Error!i64 { return database.queryInt("SELECT count(*) FROM upstreams"); } +// --------------------------------------------------------------------------- +// REST surface (milestone 8) +// --------------------------------------------------------------------------- +// +// The write shape is `model.UpstreamServer`: its four fields are exactly the +// columns of the table, so the REST layer needs no second shape for them. + +pub const UpstreamRow = struct { + id: i64, + url: []const u8, + priority: i32, + enabled: bool, + tls_name: []const u8, +}; + +const list_upstream_rows_sql = + \\SELECT id, url, priority, enabled, tls_name FROM upstreams ORDER BY priority, url +; + +const get_upstream_sql = + \\SELECT id, url, priority, enabled, tls_name FROM upstreams WHERE id = ?1 +; + +/// Same order as `listUpstreams`; every string is a heap copy owned by `gpa`. +pub fn listUpstreamRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(UpstreamRow) { + var stmt = try database.prepare(list_upstream_rows_sql); + defer stmt.deinit(); + + var out: std.ArrayList(UpstreamRow) = .empty; + errdefer out.deinit(gpa); + errdefer freeUpstreamRows(gpa, out.items); + + while (try stmt.step()) { + const row = try readUpstreamRow(&stmt, gpa); + errdefer freeUpstreamRow(gpa, row); + try out.append(gpa, row); + } + return out; +} + +pub fn freeUpstreamRow(gpa: Allocator, row: UpstreamRow) void { + gpa.free(row.url); + gpa.free(row.tls_name); +} + +pub fn freeUpstreamRows(gpa: Allocator, items: []const UpstreamRow) void { + for (items) |item| freeUpstreamRow(gpa, item); +} + +pub fn getUpstream(database: *db.Db, gpa: Allocator, id: i64) db.Error!?UpstreamRow { + var stmt = try database.prepare(get_upstream_sql); + defer stmt.deinit(); + try stmt.bindInt(1, id); + if (!try stmt.step()) return null; + return try readUpstreamRow(&stmt, gpa); +} + +fn readUpstreamRow(stmt: *db.Stmt, gpa: Allocator) db.Error!UpstreamRow { + const url = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(url); + const tls_name = try stmt.columnTextAlloc(gpa, 4); + errdefer gpa.free(tls_name); + // The column is a 64-bit integer; the row field is `i32`. A value outside + // that range means something other than nxdns wrote the row. + const priority = std.math.cast(i32, stmt.columnInt(2)) orelse return error.Mismatch; + return .{ + .id = stmt.columnInt(0), + .url = url, + .priority = priority, + .enabled = stmt.columnBool(3), + .tls_name = tls_name, + }; +} + +/// `error.Constraint`: `upstreams.url` is UNIQUE. +pub fn insertUpstreamRow(database: *db.Db, item: model.UpstreamServer) db.Error!i64 { + var stmt = try database.prepare( + "INSERT INTO upstreams (url, priority, enabled, tls_name) VALUES (?1, ?2, ?3, ?4)", + ); + defer stmt.deinit(); + try stmt.bindText(1, item.url); + try stmt.bindInt(2, item.priority); + try stmt.bindBool(3, item.enabled); + try stmt.bindText(4, item.tls_name); + try stmt.exec(); + return database.lastInsertRowid(); +} + +/// `error.NotFound`: no upstream holds `id`. `error.Constraint`: +/// `upstreams.url` is UNIQUE. +pub fn updateUpstream(database: *db.Db, id: i64, item: model.UpstreamServer) db.Error!void { + var stmt = try database.prepare( + "UPDATE upstreams SET url = ?2, priority = ?3, enabled = ?4, tls_name = ?5 WHERE id = ?1", + ); + defer stmt.deinit(); + try stmt.bindInt(1, id); + try stmt.bindText(2, item.url); + try stmt.bindInt(3, item.priority); + try stmt.bindBool(4, item.enabled); + try stmt.bindText(5, item.tls_name); + return crud.execStrict(database, &stmt); +} + +/// `error.NotFound`: no upstream holds `id`. Nothing references `upstreams`, so +/// a delete cannot violate a constraint. +pub fn deleteUpstream(database: *db.Db, id: i64) db.Error!void { + var stmt = try database.prepare("DELETE FROM upstreams WHERE id = ?1"); + defer stmt.deinit(); + try stmt.bindInt(1, id); + return crud.execStrict(database, &stmt); +} + // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- @@ -151,3 +266,89 @@ fn listUpstreamsUnderFailure(gpa: Allocator) !void { test "listUpstreams is leak-safe under allocation failure" { try testing.checkAllAllocationFailures(testing.allocator, listUpstreamsUnderFailure, .{}); } + +// --- REST surface ---------------------------------------------------------- + +test "an upstream round-trips through insert, get, list, update and delete" { + var database = try openMigrated(); + defer database.close(); + + const id = try insertUpstreamRow(&database, .{ + .url = "tls://1.1.1.1:853", + .priority = 10, + .tls_name = "one.one.one.one", + }); + + const fetched = (try getUpstream(&database, testing.allocator, id)).?; + defer freeUpstreamRow(testing.allocator, fetched); + try testing.expectEqual(id, fetched.id); + try testing.expectEqualStrings("tls://1.1.1.1:853", fetched.url); + try testing.expectEqual(@as(i32, 10), fetched.priority); + try testing.expect(fetched.enabled); + try testing.expectEqualStrings("one.one.one.one", fetched.tls_name); + + const second = try insertUpstreamRow(&database, .{ .url = "udp://9.9.9.9:53", .priority = 20 }); + + var rows = try listUpstreamRows(&database, testing.allocator); + defer rows.deinit(testing.allocator); + defer freeUpstreamRows(testing.allocator, rows.items); + try testing.expectEqual(@as(usize, 2), rows.items.len); + try testing.expectEqual(id, rows.items[0].id); + try testing.expectEqual(second, rows.items[1].id); + try testing.expectEqualStrings("", rows.items[1].tls_name); + + try updateUpstream(&database, id, .{ + .url = "tls://1.0.0.1:853", + .priority = 5, + .enabled = false, + .tls_name = "", + }); + const updated = (try getUpstream(&database, testing.allocator, id)).?; + defer freeUpstreamRow(testing.allocator, updated); + try testing.expectEqualStrings("tls://1.0.0.1:853", updated.url); + try testing.expectEqual(@as(i32, 5), updated.priority); + try testing.expect(!updated.enabled); + try testing.expectEqualStrings("", updated.tls_name); + + try deleteUpstream(&database, id); + try testing.expectEqual(@as(?UpstreamRow, null), try getUpstream(&database, testing.allocator, id)); + try testing.expectEqual(@as(i64, 1), try countUpstreams(&database)); +} + +test "upstream update and delete report NotFound for an id no row holds" { + var database = try openMigrated(); + defer database.close(); + + try testing.expectError(error.NotFound, updateUpstream(&database, 404, .{ .url = "udp://9.9.9.9:53" })); + try testing.expectError(error.NotFound, deleteUpstream(&database, 404)); + try testing.expectEqual(@as(?UpstreamRow, null), try getUpstream(&database, testing.allocator, 404)); +} + +test "a duplicate upstream url surfaces as error.Constraint on insert and on update" { + var database = try openMigrated(); + defer database.close(); + + _ = try insertUpstreamRow(&database, .{ .url = "udp://9.9.9.9:53" }); + const other = try insertUpstreamRow(&database, .{ .url = "udp://1.1.1.1:53" }); + + try testing.expectError(error.Constraint, insertUpstreamRow(&database, .{ .url = "udp://9.9.9.9:53" })); + try testing.expectError(error.Constraint, updateUpstream(&database, other, .{ .url = "udp://9.9.9.9:53" })); + try testing.expectEqual(@as(i64, 2), try countUpstreams(&database)); +} + +fn upstreamRowsUnderFailure(gpa: Allocator) !void { + var database = try openMigrated(); + defer database.close(); + try seedUpstreams(&database); + + var rows = try listUpstreamRows(&database, gpa); + defer rows.deinit(gpa); + defer freeUpstreamRows(gpa, rows.items); + + const one = (try getUpstream(&database, gpa, rows.items[0].id)).?; + defer freeUpstreamRow(gpa, one); +} + +test "the upstream read surface is leak-safe under allocation failure" { + try testing.checkAllAllocationFailures(testing.allocator, upstreamRowsUnderFailure, .{}); +} diff --git a/src/storage/retention.zig b/src/storage/retention.zig index 1ed2435..0bd5959 100644 --- a/src/storage/retention.zig +++ b/src/storage/retention.zig @@ -26,6 +26,7 @@ pub const vacuum_every_passes = 7; /// expresses, so a finer schedule would prune nothing new. pub const pass_interval_s = 86_400; +/// A consistent copy of the counters, for `/metrics` and the health rollup. pub const Stats = struct { passes: u64 = 0, rows_pruned: u64 = 0, @@ -33,12 +34,34 @@ pub const Stats = struct { vacuums: u64 = 0, }; +/// The live counters. Atomic because the retention task writes them and the web +/// task reads them, on different threads, with no lock between the two — the +/// same shape the query logger uses for its own counters. +const Counters = struct { + passes: std.atomic.Value(u64) = .init(0), + rows_pruned: std.atomic.Value(u64) = .init(0), + checkpoints: std.atomic.Value(u64) = .init(0), + vacuums: std.atomic.Value(u64) = .init(0), +}; + pub const Retention = struct { cfg: model.Logging, - stats: Stats, + counters: Counters, pub fn init(cfg: model.Logging) Retention { - return .{ .cfg = cfg, .stats = .{} }; + return .{ .cfg = cfg, .counters = .{} }; + } + + /// The four counters, read one at a time. A scrape that lands mid-pass can + /// see a pass counted before the rows it pruned are; the alternative is a + /// lock on the pass itself, which buys a consistency no consumer needs. + pub fn snapshotStats(self: *const Retention) Stats { + return .{ + .passes = self.counters.passes.load(.monotonic), + .rows_pruned = self.counters.rows_pruned.load(.monotonic), + .checkpoints = self.counters.checkpoints.load(.monotonic), + .vacuums = self.counters.vacuums.load(.monotonic), + }; } /// One pass: prune, checkpoint, and on every seventh pass vacuum. @@ -53,29 +76,36 @@ pub const Retention = struct { /// /// `database` must be a connection no other task uses; see `run`. pub fn runOnce(self: *Retention, io: std.Io, database: *db.Db) void { - self.stats.passes += 1; + const pass = add(&self.counters.passes, 1) + 1; const cutoff = std.Io.Clock.real.now(io).toSeconds() - model.retentionSeconds(self.cfg); if (queries_repo.pruneOlderThan(database, cutoff)) |deleted| { - self.stats.rows_pruned += @intCast(deleted); + _ = add(&self.counters.rows_pruned, @intCast(deleted)); } else |err| { log.warn("retention prune before {d} failed: {s}", .{ cutoff, @errorName(err) }); } if (queries_repo.checkpointTruncate(database)) { - self.stats.checkpoints += 1; + _ = add(&self.counters.checkpoints, 1); } else |err| { log.warn("retention checkpoint failed: {s}", .{@errorName(err)}); } - if (self.stats.passes % vacuum_every_passes != 0) return; + if (pass % vacuum_every_passes != 0) return; if (queries_repo.vacuum(database)) { - self.stats.vacuums += 1; + _ = add(&self.counters.vacuums, 1); } else |err| { log.warn("retention vacuum failed: {s}", .{@errorName(err)}); } } + /// Returns the value before the addition, which is what the pass counter + /// needs: only this task increments it, so `previous + 1` is this pass's + /// number. + fn add(counter: *std.atomic.Value(u64), delta: u64) u64 { + return counter.fetchAdd(delta, .monotonic); + } + /// Daily loop, first pass immediately. Phase 7 starts it. /// /// `boot` rather than `awake`: a box that suspends overnight must still see @@ -159,10 +189,10 @@ test "a pass prunes the rows past the retention window and keeps the rest" { retention.runOnce(io, &database); try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&database)); - try testing.expectEqual(@as(u64, 1), retention.stats.passes); - try testing.expectEqual(@as(u64, 2), retention.stats.rows_pruned); - try testing.expectEqual(@as(u64, 1), retention.stats.checkpoints); - try testing.expectEqual(@as(u64, 0), retention.stats.vacuums); + try testing.expectEqual(@as(u64, 1), retention.snapshotStats().passes); + try testing.expectEqual(@as(u64, 2), retention.snapshotStats().rows_pruned); + try testing.expectEqual(@as(u64, 1), retention.snapshotStats().checkpoints); + try testing.expectEqual(@as(u64, 0), retention.snapshotStats().vacuums); } test "the cutoff follows retention_days" { @@ -182,12 +212,12 @@ test "the cutoff follows retention_days" { var keeps: Retention = .init(.{ .retention_days = 7 }); keeps.runOnce(io, &database); try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database)); - try testing.expectEqual(@as(u64, 0), keeps.stats.rows_pruned); + try testing.expectEqual(@as(u64, 0), keeps.snapshotStats().rows_pruned); var prunes: Retention = .init(.{ .retention_days = 1 }); prunes.runOnce(io, &database); try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database)); - try testing.expectEqual(@as(u64, 1), prunes.stats.rows_pruned); + try testing.expectEqual(@as(u64, 1), prunes.snapshotStats().rows_pruned); } test "the seventh pass vacuums and the six before it do not" { @@ -201,17 +231,17 @@ test "the seventh pass vacuums and the six before it do not" { var retention: Retention = .init(.{}); for (0..6) |_| { retention.runOnce(io, &database); - try testing.expectEqual(@as(u64, 0), retention.stats.vacuums); + try testing.expectEqual(@as(u64, 0), retention.snapshotStats().vacuums); } retention.runOnce(io, &database); - try testing.expectEqual(@as(u64, 7), retention.stats.passes); - try testing.expectEqual(@as(u64, 1), retention.stats.vacuums); - try testing.expectEqual(@as(u64, 7), retention.stats.checkpoints); + try testing.expectEqual(@as(u64, 7), retention.snapshotStats().passes); + try testing.expectEqual(@as(u64, 1), retention.snapshotStats().vacuums); + try testing.expectEqual(@as(u64, 7), retention.snapshotStats().checkpoints); for (0..7) |_| retention.runOnce(io, &database); - try testing.expectEqual(@as(u64, 14), retention.stats.passes); - try testing.expectEqual(@as(u64, 2), retention.stats.vacuums); + try testing.expectEqual(@as(u64, 14), retention.snapshotStats().passes); + try testing.expectEqual(@as(u64, 2), retention.snapshotStats().vacuums); } test "a pass over an empty database still counts" { @@ -225,9 +255,9 @@ test "a pass over an empty database still counts" { var retention: Retention = .init(.{}); retention.runOnce(io, &database); - try testing.expectEqual(@as(u64, 1), retention.stats.passes); - try testing.expectEqual(@as(u64, 0), retention.stats.rows_pruned); - try testing.expectEqual(@as(u64, 1), retention.stats.checkpoints); + try testing.expectEqual(@as(u64, 1), retention.snapshotStats().passes); + try testing.expectEqual(@as(u64, 0), retention.snapshotStats().rows_pruned); + try testing.expectEqual(@as(u64, 1), retention.snapshotStats().checkpoints); try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database)); } @@ -250,10 +280,10 @@ test "a failing prune counts the pass and leaves the rows alone" { retention.runOnce(io, &database); try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database)); - try testing.expectEqual(@as(u64, 1), retention.stats.passes); - try testing.expectEqual(@as(u64, 0), retention.stats.rows_pruned); + try testing.expectEqual(@as(u64, 1), retention.snapshotStats().passes); + try testing.expectEqual(@as(u64, 0), retention.snapshotStats().rows_pruned); // The checkpoint runs whether or not the prune did. - try testing.expectEqual(@as(u64, 1), retention.stats.checkpoints); + try testing.expectEqual(@as(u64, 1), retention.snapshotStats().checkpoints); } test "the next pass retries what the failed one could not do" { @@ -279,6 +309,6 @@ test "the next pass retries what the failed one could not do" { retention.runOnce(io, &database); try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database)); - try testing.expectEqual(@as(u64, 2), retention.stats.passes); - try testing.expectEqual(@as(u64, 2), retention.stats.rows_pruned); + try testing.expectEqual(@as(u64, 2), retention.snapshotStats().passes); + try testing.expectEqual(@as(u64, 2), retention.snapshotStats().rows_pruned); } diff --git a/src/tests.zig b/src/tests.zig index a4858a4..0e28b32 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -34,6 +34,7 @@ comptime { _ = @import("storage/migrations.zig"); _ = @import("storage/querylog_schema.zig"); _ = @import("storage/repositories/context.zig"); + _ = @import("storage/repositories/crud.zig"); _ = @import("storage/repositories/groups_repo.zig"); _ = @import("storage/repositories/clients_repo.zig"); _ = @import("storage/repositories/upstreams_repo.zig"); @@ -76,6 +77,37 @@ comptime { _ = @import("server/clients.zig"); _ = @import("server/shutdown.zig"); _ = @import("server/phase7_integration_test.zig"); + _ = @import("web/sse.zig"); + _ = @import("server/query_sink.zig"); + _ = @import("web/auth.zig"); + _ = @import("web/api_limiter.zig"); + _ = @import("web/http_util.zig"); + _ = @import("web/router.zig"); + _ = @import("web/server.zig"); + _ = @import("web/server_integration_test.zig"); + _ = @import("server/local_tables.zig"); + _ = @import("web/metrics.zig"); + _ = @import("web/handlers/stats.zig"); + _ = @import("web/handlers/queries.zig"); + _ = @import("web/handlers/lookup.zig"); + _ = @import("web/handlers/upstream_health.zig"); + _ = @import("web/handlers/health.zig"); + _ = @import("web/handlers/version.zig"); + _ = @import("web/handlers/mutations.zig"); + _ = @import("web/handlers/groups.zig"); + _ = @import("web/handlers/blocklists.zig"); + _ = @import("web/handlers/rules.zig"); + _ = @import("web/handlers/local.zig"); + _ = @import("web/handlers/clients.zig"); + _ = @import("web/handlers/upstreams.zig"); + _ = @import("web/handlers/pause.zig"); + _ = @import("web/handlers/settings.zig"); + _ = @import("web/handlers/auth.zig"); + _ = @import("web/static.zig"); + _ = @import("web/openapi.zig"); + _ = @import("web/routes.zig"); + _ = @import("web/handlers/live.zig"); + _ = @import("web/web_integration_test.zig"); } extern fn sqlite3_libversion() [*:0]const u8; diff --git a/src/web/api_limiter.zig b/src/web/api_limiter.zig new file mode 100644 index 0000000..8ca8f36 --- /dev/null +++ b/src/web/api_limiter.zig @@ -0,0 +1,687 @@ +//! Token-bucket rate limiter for the REST API (PLAN §10; milestone-8 ruling 19). +//! +//! The DNS listeners use a fixed window (`server/rate_limiter.zig`); the API +//! uses a bucket, because an admin UI legitimately fires a burst of requests +//! when a page loads and then goes quiet. A bucket admits that burst up to its +//! capacity and still holds the long-run rate to `rate_per_min` per minute. +//! +//! One bucket per client address, in a table bounded at `max_clients`. When +//! the table is full, an unknown address first reclaims the slot of a bucket +//! that a fresh one would answer identically to (refills to capacity, holds no +//! SSE connection); if no such bucket exists, the address is refused and +//! counted under `untracked`. Admitting it instead — the DNS limiter's choice — +//! would let a client cycling addresses bypass the limiter entirely, and here +//! the DNS limiter's reason does not apply: API clients speak TCP, so a flood +//! of spoofed sources cannot fill the table, and the operator on the box stays +//! covered by the localhost exemption. +//! +//! The same table carries each address's live SSE connection count, since both +//! limits key on the address and both are taken and released around one +//! request. `/metrics` and `/api/health` never reach this file — Prometheus must +//! not be told 429 (ruling 19) — and the router is what exempts them. +//! +//! Timestamps come from the caller, as everywhere else in this codebase. Pass +//! the `.awake` clock: elapsed time is what refills a bucket, and a wall-clock +//! step must not hand out a minute of tokens. +//! +//! Not lock-free but self-locking: connection tasks run concurrently, so the +//! mutex lives here rather than in every caller. + +const std = @import("std"); + +const address = @import("../platform/address.zig"); + +const Allocator = std.mem.Allocator; + +/// Upper bound on tracked addresses. The table never grows past it, so `check` +/// never allocates. +pub const max_clients = 4096; + +/// A bucket refills its whole capacity over this window (ruling 19). +pub const window_seconds = 60; + +const window_ns: i96 = @as(i96, window_seconds) * std.time.ns_per_s; + +/// Tokens are counted in millionths so that a fraction of a token earned +/// between two requests is not lost to integer division. One whole token is +/// `token_scale`. +const token_scale: u64 = 1_000_000; + +pub const Config = struct { + /// `web.api_rate_limit_per_min`: both the bucket capacity and the refill per + /// minute. `validate.zig` rejects zero. + rate_per_min: u32, + /// `web.api_localhost_exempt`. The box's own requests — a script on the + /// server, a health probe in a container namespace — are usually the + /// operator's own and are not what the limiter defends against. + localhost_exempt: bool = true, + /// `web.sse_max_connections_per_ip`. + sse_max_per_ip: u16, +}; + +/// `allowed + refused` equals the number of `check` calls that were not exempt. +/// `untracked` counts the subset of `refused` that a full table could not hold +/// a bucket for, and `exempt` the calls that never consulted a bucket. +pub const Stats = struct { + allowed: u64 = 0, + refused: u64 = 0, + untracked: u64 = 0, + exempt: u64 = 0, + sse_refused: u64 = 0, +}; + +pub const Result = struct { + allowed: bool, + /// Seconds until one token is available again, for the `Retry-After` + /// header. Zero when the request was allowed. Never zero when it was + /// refused: a client told to retry after zero seconds retries immediately. + retry_after_s: u32 = 0, + + pub const ok: Result = .{ .allowed = true }; +}; + +const Bucket = struct { + /// Tokens held, scaled by `token_scale`. + tokens: u64, + /// When `tokens` was last brought up to date. + updated_ns: i96, + /// Live SSE responses this address holds open. + sse: u16, +}; + +const Table = std.AutoHashMapUnmanaged(address.NetAddress.Key, Bucket); + +pub const ApiLimiter = struct { + /// Guards `table` and `stats`; see the file comment. + mutex: std.Io.Mutex, + gpa: Allocator, + config: Config, + capacity: u64, + table: Table, + /// `sweep` collects the keys to drop before removing any, because a removal + /// invalidates a live iterator. Owning the buffer keeps `sweep` + /// allocation-free. + stale_keys: []address.NetAddress.Key, + stats: Stats, + + /// Asserts `config.rate_per_min` is nonzero; `validate.zig` rejects a zero + /// rate before a config reaches this far. + pub fn init(gpa: Allocator, config: Config) Allocator.Error!ApiLimiter { + std.debug.assert(config.rate_per_min > 0); + + var table: Table = .empty; + errdefer table.deinit(gpa); + try table.ensureTotalCapacity(gpa, max_clients); + + const stale_keys = try gpa.alloc(address.NetAddress.Key, max_clients); + + return .{ + .mutex = .init, + .gpa = gpa, + .config = config, + .capacity = @as(u64, config.rate_per_min) * token_scale, + .table = table, + .stale_keys = stale_keys, + .stats = .{}, + }; + } + + pub fn deinit(self: *ApiLimiter) void { + self.table.deinit(self.gpa); + self.gpa.free(self.stale_keys); + self.* = undefined; + } + + /// Spends one token for a request from `addr`. Never allocates, never fails. + pub fn check(self: *ApiLimiter, io: std.Io, now: std.Io.Timestamp, addr: address.NetAddress) Result { + if (self.config.localhost_exempt and isLoopback(addr)) { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + self.stats.exempt += 1; + return .ok; + } + + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + const bucket = self.bucketLocked(addr.key(), now) orelse { + self.stats.untracked += 1; + self.stats.refused += 1; + return .{ .allowed = false, .retry_after_s = self.retryAfter(0) }; + }; + + if (bucket.tokens < token_scale) { + self.stats.refused += 1; + return .{ .allowed = false, .retry_after_s = self.retryAfter(bucket.tokens) }; + } + bucket.tokens -= token_scale; + self.stats.allowed += 1; + return .ok; + } + + /// Takes an SSE slot for `addr`. A connect also spends a token, which the + /// caller does with `check` first (ruling 19); this call is only the + /// per-address connection cap. + /// + /// The cap applies to loopback too: it bounds a fixed resource (subscriber + /// slots in the hub), which the rate exemption has no bearing on. + pub fn tryAcquireSse(self: *ApiLimiter, io: std.Io, now: std.Io.Timestamp, addr: address.NetAddress) bool { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + // A full table cannot hold the counter, so it cannot enforce the cap + // either. Refusing is consistent with `check`: an uncounted stream + // could otherwise reach the hub's global cap past the per-IP one. + const bucket = self.bucketLocked(addr.key(), now) orelse { + self.stats.sse_refused += 1; + return false; + }; + if (bucket.sse >= self.config.sse_max_per_ip) { + self.stats.sse_refused += 1; + return false; + } + bucket.sse += 1; + return true; + } + + /// Releases a slot taken by `tryAcquireSse`. A release whose bucket was + /// swept finds no counter and does nothing: the alternative is an + /// underflow on a path that must not fail. + pub fn releaseSse(self: *ApiLimiter, io: std.Io, addr: address.NetAddress) void { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + const bucket = self.table.getPtr(addr.key()) orelse return; + if (bucket.sse > 0) bucket.sse -= 1; + } + + /// Live SSE connections held by `addr`. + pub fn sseConnections(self: *ApiLimiter, io: std.Io, addr: address.NetAddress) u16 { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + const bucket = self.table.getPtr(addr.key()) orelse return 0; + return bucket.sse; + } + + /// Drops every bucket that is full, holds no SSE connection and has been + /// idle for a full window: such a bucket answers exactly as a fresh one + /// would, so forgetting it changes no decision. Returns how many it dropped. + pub fn sweep(self: *ApiLimiter, io: std.Io, now: std.Io.Timestamp) u32 { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + var stale_count: u32 = 0; + var it = self.table.iterator(); + while (it.next()) |entry| { + const bucket = entry.value_ptr; + if (bucket.sse != 0) continue; + if (now.nanoseconds - bucket.updated_ns < window_ns) continue; + if (refilled(bucket.*, now, self.capacity).tokens < self.capacity) continue; + self.stale_keys[stale_count] = entry.key_ptr.*; + stale_count += 1; + } + + for (self.stale_keys[0..stale_count]) |key| { + const removed = self.table.remove(key); + std.debug.assert(removed); + } + return stale_count; + } + + /// Addresses currently holding a bucket. Reaching `max_clients` with no + /// reclaimable bucket is what turns unknown addresses into `untracked` + /// refusals. + pub fn trackedClients(self: *ApiLimiter, io: std.Io) u32 { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + return self.table.count(); + } + + pub fn snapshotStats(self: *ApiLimiter, io: std.Io) Stats { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + return self.stats; + } + + /// The address's bucket, refilled to `now`, or null when the table is full + /// and no slot can be reclaimed for the unknown address. Caller holds the + /// mutex. + fn bucketLocked(self: *ApiLimiter, key: address.NetAddress.Key, now: std.Io.Timestamp) ?*Bucket { + if (self.table.getPtr(key)) |bucket| { + const state = refilled(bucket.*, now, self.capacity); + bucket.tokens = state.tokens; + bucket.updated_ns = state.updated_ns; + return bucket; + } + if (self.table.count() >= max_clients and !self.evictReclaimableLocked(now)) return null; + + const gop = self.table.getOrPutAssumeCapacity(key); + gop.value_ptr.* = .{ .tokens = self.capacity, .updated_ns = now.nanoseconds, .sse = 0 }; + return gop.value_ptr; + } + + /// Removes one bucket a fresh bucket would answer identically to: full + /// after refill and holding no SSE connection. Unlike `sweep` it demands + /// no idle window — that hysteresis avoids churn in background sweeping + /// but forgets nothing here, since a full bucket decides as a fresh one + /// does. Returns whether a slot was reclaimed. Caller holds the mutex. + fn evictReclaimableLocked(self: *ApiLimiter, now: std.Io.Timestamp) bool { + var it = self.table.iterator(); + while (it.next()) |entry| { + if (entry.value_ptr.sse != 0) continue; + if (refilled(entry.value_ptr.*, now, self.capacity).tokens < self.capacity) continue; + const removed = self.table.remove(entry.key_ptr.*); + std.debug.assert(removed); + return true; + } + return false; + } + + /// Seconds until `tokens` reaches one whole token, rounded up and never + /// below one. + fn retryAfter(self: *const ApiLimiter, tokens: u64) u32 { + const missing = token_scale - tokens; + // missing tokens / (rate_per_min tokens per window) seconds, rounded up. + const seconds = (missing * window_seconds + self.capacity - 1) / self.capacity; + return @intCast(@max(1, seconds)); + } +}; + +const Refill = struct { tokens: u64, updated_ns: i96 }; + +/// `bucket` brought up to `now`. +/// +/// The time that bought fewer than one microtoken stays on the clock rather +/// than being rounded away: `updated_ns` only advances by the span actually +/// converted into tokens. Without that, a client polling faster than one +/// microtoken per request would never refill at all, and a refill boundary +/// would land a microtoken short of where the configured rate puts it. +/// +/// A backwards timestamp earns nothing and resets the clock, so a clock that +/// steps back cannot later be credited for the time it repeated. +fn refilled(bucket: Bucket, now: std.Io.Timestamp, capacity: u64) Refill { + const elapsed_ns = now.nanoseconds - bucket.updated_ns; + if (elapsed_ns <= 0) return .{ .tokens = @min(bucket.tokens, capacity), .updated_ns = now.nanoseconds }; + if (bucket.tokens >= capacity) return .{ .tokens = capacity, .updated_ns = now.nanoseconds }; + // A whole window refills the bucket whatever it held, and short-circuiting + // here also keeps the multiplication below inside i96. + if (elapsed_ns >= window_ns) return .{ .tokens = capacity, .updated_ns = now.nanoseconds }; + + const capacity_96: i96 = @intCast(capacity); + const gained = @divTrunc(elapsed_ns * capacity_96, window_ns); + if (gained == 0) return .{ .tokens = bucket.tokens, .updated_ns = bucket.updated_ns }; + + const tokens = bucket.tokens + @as(u64, @intCast(gained)); + if (tokens >= capacity) return .{ .tokens = capacity, .updated_ns = now.nanoseconds }; + return .{ .tokens = tokens, .updated_ns = bucket.updated_ns + @divTrunc(gained * window_ns, capacity_96) }; +} + +/// 127.0.0.0/8 and ::1, the addresses a request from the box itself carries. +/// An IPv4-mapped loopback address has already normalized to `.ip4` by the time +/// a `NetAddress` exists (`address.zig:51`). +pub fn isLoopback(addr: address.NetAddress) bool { + return switch (addr) { + .ip4 => |b| b[0] == 127, + .ip6 => |b| std.mem.eql(u8, &b, &[_]u8{0} ** 15 ++ [_]u8{1}), + }; +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +fn at(seconds: i64) std.Io.Timestamp { + return .{ .nanoseconds = @as(i96, seconds) * std.time.ns_per_s }; +} + +fn atMillis(millis: i64) std.Io.Timestamp { + return .{ .nanoseconds = @as(i96, millis) * std.time.ns_per_ms }; +} + +fn v4(a: u8, b: u8, c: u8, d: u8) address.NetAddress { + return .{ .ip4 = .{ a, b, c, d } }; +} + +fn indexed(index: u32) address.NetAddress { + var octets: [4]u8 = undefined; + std.mem.writeInt(u32, &octets, index, .big); + return .{ .ip4 = octets }; +} + +const Fixture = struct { + threaded: std.Io.Threaded, + limiter: ApiLimiter, + + fn init(config: Config) !*Fixture { + const self = try testing.allocator.create(Fixture); + self.* = .{ + .threaded = .init(testing.allocator, .{}), + .limiter = try ApiLimiter.init(testing.allocator, config), + }; + return self; + } + + fn deinit(self: *Fixture) void { + self.limiter.deinit(); + self.threaded.deinit(); + testing.allocator.destroy(self); + } + + fn io(self: *Fixture) std.Io { + return self.threaded.io(); + } +}; + +test "a burst up to the capacity is allowed and the next request is refused" { + const fx = try Fixture.init(.{ .rate_per_min = 60, .localhost_exempt = false, .sse_max_per_ip = 3 }); + defer fx.deinit(); + + const client = v4(192, 168, 1, 10); + for (0..60) |_| { + try testing.expect(fx.limiter.check(fx.io(), at(0), client).allowed); + } + + const refused = fx.limiter.check(fx.io(), at(0), client); + try testing.expect(!refused.allowed); + // At 60 per minute a token is worth one second. + try testing.expectEqual(@as(u32, 1), refused.retry_after_s); + + const stats = fx.limiter.snapshotStats(fx.io()); + try testing.expectEqual(@as(u64, 60), stats.allowed); + try testing.expectEqual(@as(u64, 1), stats.refused); +} + +test "an emptied bucket refills at the configured rate" { + const fx = try Fixture.init(.{ .rate_per_min = 60, .localhost_exempt = false, .sse_max_per_ip = 3 }); + defer fx.deinit(); + + const client = v4(10, 0, 0, 1); + for (0..60) |_| try testing.expect(fx.limiter.check(fx.io(), at(0), client).allowed); + + // One token is worth exactly one second, so 999 ms is still short. + try testing.expect(!fx.limiter.check(fx.io(), atMillis(999), client).allowed); + try testing.expect(fx.limiter.check(fx.io(), atMillis(1000), client).allowed); + try testing.expect(!fx.limiter.check(fx.io(), atMillis(1000), client).allowed); + + // The fractions left behind by the refused calls still accumulate. + try testing.expect(fx.limiter.check(fx.io(), atMillis(2000), client).allowed); + + // A long idle period refills no further than the capacity. + for (0..60) |_| try testing.expect(fx.limiter.check(fx.io(), at(3600), client).allowed); + try testing.expect(!fx.limiter.check(fx.io(), at(3600), client).allowed); +} + +test "retry-after reports the wait for one token and is never zero" { + const fx = try Fixture.init(.{ .rate_per_min = 6, .localhost_exempt = false, .sse_max_per_ip = 3 }); + defer fx.deinit(); + + const client = v4(10, 0, 0, 2); + for (0..6) |_| try testing.expect(fx.limiter.check(fx.io(), at(0), client).allowed); + + // At 6 per minute a token takes 10 seconds. + try testing.expectEqual(@as(u32, 10), fx.limiter.check(fx.io(), at(0), client).retry_after_s); + try testing.expectEqual(@as(u32, 5), fx.limiter.check(fx.io(), at(5), client).retry_after_s); + // Under a second of waiting still reports one second. + try testing.expectEqual(@as(u32, 1), fx.limiter.check(fx.io(), atMillis(9_500), client).retry_after_s); + try testing.expect(fx.limiter.check(fx.io(), at(10), client).allowed); +} + +test "a rate of one still admits one request per minute" { + const fx = try Fixture.init(.{ .rate_per_min = 1, .localhost_exempt = false, .sse_max_per_ip = 1 }); + defer fx.deinit(); + + const client = v4(10, 0, 0, 3); + try testing.expect(fx.limiter.check(fx.io(), at(0), client).allowed); + const refused = fx.limiter.check(fx.io(), at(0), client); + try testing.expect(!refused.allowed); + try testing.expectEqual(@as(u32, 60), refused.retry_after_s); + try testing.expect(!fx.limiter.check(fx.io(), at(59), client).allowed); + try testing.expect(fx.limiter.check(fx.io(), at(60), client).allowed); +} + +test "clients hold independent buckets" { + const fx = try Fixture.init(.{ .rate_per_min = 1, .localhost_exempt = false, .sse_max_per_ip = 3 }); + defer fx.deinit(); + + const a = v4(192, 168, 1, 20); + const b = try address.NetAddress.parse("fd00::20"); + try testing.expect(fx.limiter.check(fx.io(), at(0), a).allowed); + try testing.expect(!fx.limiter.check(fx.io(), at(0), a).allowed); + try testing.expect(fx.limiter.check(fx.io(), at(0), b).allowed); + try testing.expect(!fx.limiter.check(fx.io(), at(0), b).allowed); + try testing.expectEqual(@as(u32, 2), fx.limiter.trackedClients(fx.io())); +} + +test "loopback is exempt when configured and limited when not" { + const exempt = try Fixture.init(.{ .rate_per_min = 1, .localhost_exempt = true, .sse_max_per_ip = 3 }); + defer exempt.deinit(); + + for (0..10) |_| { + try testing.expect(exempt.limiter.check(exempt.io(), at(0), v4(127, 0, 0, 1)).allowed); + } + try testing.expect(exempt.limiter.check(exempt.io(), at(0), v4(127, 1, 2, 3)).allowed); + try testing.expect(exempt.limiter.check(exempt.io(), at(0), try address.NetAddress.parse("::1")).allowed); + // An exempt request consults no bucket at all. + try testing.expectEqual(@as(u32, 0), exempt.limiter.trackedClients(exempt.io())); + try testing.expectEqual(@as(u64, 12), exempt.limiter.snapshotStats(exempt.io()).exempt); + + // A LAN address is limited either way. + try testing.expect(exempt.limiter.check(exempt.io(), at(0), v4(192, 168, 1, 5)).allowed); + try testing.expect(!exempt.limiter.check(exempt.io(), at(0), v4(192, 168, 1, 5)).allowed); + + const strict = try Fixture.init(.{ .rate_per_min = 1, .localhost_exempt = false, .sse_max_per_ip = 3 }); + defer strict.deinit(); + + try testing.expect(strict.limiter.check(strict.io(), at(0), v4(127, 0, 0, 1)).allowed); + try testing.expect(!strict.limiter.check(strict.io(), at(0), v4(127, 0, 0, 1)).allowed); + try testing.expectEqual(@as(u64, 0), strict.limiter.snapshotStats(strict.io()).exempt); +} + +test "isLoopback covers both families and nothing else" { + try testing.expect(isLoopback(try address.NetAddress.parse("127.0.0.1"))); + try testing.expect(isLoopback(try address.NetAddress.parse("127.255.255.254"))); + try testing.expect(isLoopback(try address.NetAddress.parse("::1"))); + // An IPv4-mapped loopback literal normalizes to the IPv4 form. + try testing.expect(isLoopback(address.NetAddress.fromIp(try std.Io.net.IpAddress.parse("::ffff:127.0.0.1", 0)))); + + try testing.expect(!isLoopback(try address.NetAddress.parse("128.0.0.1"))); + try testing.expect(!isLoopback(try address.NetAddress.parse("0.0.0.0"))); + try testing.expect(!isLoopback(try address.NetAddress.parse("::"))); + try testing.expect(!isLoopback(try address.NetAddress.parse("fd00::1"))); +} + +test "sse connections are capped per address and released" { + const fx = try Fixture.init(.{ .rate_per_min = 300, .localhost_exempt = false, .sse_max_per_ip = 2 }); + defer fx.deinit(); + + const client = v4(192, 168, 1, 30); + const other = v4(192, 168, 1, 31); + + try testing.expect(fx.limiter.tryAcquireSse(fx.io(), at(0), client)); + try testing.expect(fx.limiter.tryAcquireSse(fx.io(), at(0), client)); + try testing.expect(!fx.limiter.tryAcquireSse(fx.io(), at(0), client)); + try testing.expectEqual(@as(u16, 2), fx.limiter.sseConnections(fx.io(), client)); + try testing.expectEqual(@as(u64, 1), fx.limiter.snapshotStats(fx.io()).sse_refused); + + // The cap is per address. + try testing.expect(fx.limiter.tryAcquireSse(fx.io(), at(0), other)); + + fx.limiter.releaseSse(fx.io(), client); + try testing.expectEqual(@as(u16, 1), fx.limiter.sseConnections(fx.io(), client)); + try testing.expect(fx.limiter.tryAcquireSse(fx.io(), at(0), client)); + + fx.limiter.releaseSse(fx.io(), client); + fx.limiter.releaseSse(fx.io(), client); + try testing.expectEqual(@as(u16, 0), fx.limiter.sseConnections(fx.io(), client)); + + // An unmatched release neither underflows nor invents a bucket. + fx.limiter.releaseSse(fx.io(), client); + fx.limiter.releaseSse(fx.io(), v4(203, 0, 113, 9)); + try testing.expectEqual(@as(u16, 0), fx.limiter.sseConnections(fx.io(), client)); + try testing.expectEqual(@as(u32, 2), fx.limiter.trackedClients(fx.io())); +} + +test "the sse cap applies to an exempt loopback client too" { + const fx = try Fixture.init(.{ .rate_per_min = 300, .localhost_exempt = true, .sse_max_per_ip = 1 }); + defer fx.deinit(); + + const local = v4(127, 0, 0, 1); + try testing.expect(fx.limiter.tryAcquireSse(fx.io(), at(0), local)); + try testing.expect(!fx.limiter.tryAcquireSse(fx.io(), at(0), local)); + fx.limiter.releaseSse(fx.io(), local); + try testing.expect(fx.limiter.tryAcquireSse(fx.io(), at(0), local)); +} + +test "sweep drops only idle full buckets and keeps sse holders" { + const fx = try Fixture.init(.{ .rate_per_min = 60, .localhost_exempt = false, .sse_max_per_ip = 3 }); + defer fx.deinit(); + + const idle = v4(10, 0, 0, 1); + const busy = v4(10, 0, 0, 2); + const streaming = v4(10, 0, 0, 3); + + try testing.expect(fx.limiter.check(fx.io(), at(0), idle).allowed); + for (0..60) |_| try testing.expect(fx.limiter.check(fx.io(), at(0), busy).allowed); + try testing.expect(fx.limiter.tryAcquireSse(fx.io(), at(0), streaming)); + try testing.expect(fx.limiter.check(fx.io(), at(0), streaming).allowed); + try testing.expectEqual(@as(u32, 3), fx.limiter.trackedClients(fx.io())); + + // Before a full window nothing is stale, even though `idle` is full again. + try testing.expectEqual(@as(u32, 0), fx.limiter.sweep(fx.io(), at(59))); + + // At 60 s both quiet buckets are full again and go; `streaming` stays + // however long it idles, because its counter is still in use. + try testing.expectEqual(@as(u32, 2), fx.limiter.sweep(fx.io(), at(60))); + try testing.expectEqual(@as(u32, 1), fx.limiter.trackedClients(fx.io())); + try testing.expectEqual(@as(u32, 0), fx.limiter.sweep(fx.io(), at(3600))); + try testing.expectEqual(@as(u16, 1), fx.limiter.sseConnections(fx.io(), streaming)); + + // Releasing the stream makes its bucket collectable. + fx.limiter.releaseSse(fx.io(), streaming); + try testing.expectEqual(@as(u32, 1), fx.limiter.sweep(fx.io(), at(3601))); + try testing.expectEqual(@as(u32, 0), fx.limiter.trackedClients(fx.io())); + + // A swept client starts from a full bucket rather than inheriting a count. + for (0..60) |_| try testing.expect(fx.limiter.check(fx.io(), at(3601), busy).allowed); +} + +test "a full table with no reclaimable bucket refuses unknown clients" { + const fx = try Fixture.init(.{ .rate_per_min = 1, .localhost_exempt = false, .sse_max_per_ip = 1 }); + defer fx.deinit(); + + // Every bucket spends its only token, so at t=0 none refills to capacity. + for (0..max_clients) |i| { + try testing.expect(fx.limiter.check(fx.io(), at(0), indexed(@intCast(i))).allowed); + } + try testing.expectEqual(@as(u32, max_clients), fx.limiter.trackedClients(fx.io())); + try testing.expectEqual(@as(u64, 0), fx.limiter.snapshotStats(fx.io()).untracked); + + const newcomer = indexed(max_clients); + const refused = fx.limiter.check(fx.io(), at(0), newcomer); + try testing.expect(!refused.allowed); + try testing.expectEqual(@as(u32, 60), refused.retry_after_s); + try testing.expectEqual(@as(u64, 1), fx.limiter.snapshotStats(fx.io()).untracked); + try testing.expectEqual(@as(u64, 1), fx.limiter.snapshotStats(fx.io()).refused); + // The refusal did not displace a tracked client. + try testing.expectEqual(@as(u32, max_clients), fx.limiter.trackedClients(fx.io())); + + // No slot means no SSE counter, so the stream is refused too. + try testing.expect(!fx.limiter.tryAcquireSse(fx.io(), at(0), newcomer)); + try testing.expectEqual(@as(u64, 1), fx.limiter.snapshotStats(fx.io()).sse_refused); + + // A tracked client is still limited while the table is full. + try testing.expect(!fx.limiter.check(fx.io(), at(0), indexed(0)).allowed); + + // Sweeping frees room and the newcomer becomes tracked. + try testing.expectEqual(@as(u32, max_clients), fx.limiter.sweep(fx.io(), at(3600))); + try testing.expect(fx.limiter.check(fx.io(), at(3600), newcomer).allowed); + try testing.expectEqual(@as(u32, 1), fx.limiter.trackedClients(fx.io())); +} + +test "a full table evicts a refilled bucket to admit a newcomer" { + const fx = try Fixture.init(.{ .rate_per_min = 1, .localhost_exempt = false, .sse_max_per_ip = 1 }); + defer fx.deinit(); + + for (0..max_clients) |i| { + try testing.expect(fx.limiter.check(fx.io(), at(0), indexed(@intCast(i))).allowed); + } + + // At t=60 every drained bucket has refilled to capacity and is fair game. + const newcomer = indexed(max_clients); + try testing.expect(fx.limiter.check(fx.io(), at(60), newcomer).allowed); + try testing.expectEqual(@as(u32, max_clients), fx.limiter.trackedClients(fx.io())); + try testing.expectEqual(@as(u64, 0), fx.limiter.snapshotStats(fx.io()).untracked); + + // The newcomer got a real bucket: its second request is rate-limited. + try testing.expect(!fx.limiter.check(fx.io(), at(60), newcomer).allowed); + + // An SSE acquire can reclaim a slot the same way. + try testing.expect(fx.limiter.tryAcquireSse(fx.io(), at(60), indexed(max_clients + 1))); + try testing.expectEqual(@as(u32, max_clients), fx.limiter.trackedClients(fx.io())); +} + +test "buckets holding sse connections are never evicted" { + const fx = try Fixture.init(.{ .rate_per_min = 1, .localhost_exempt = false, .sse_max_per_ip = 1 }); + defer fx.deinit(); + + // Each bucket keeps its full token balance but holds a live stream. + for (0..max_clients) |i| { + try testing.expect(fx.limiter.tryAcquireSse(fx.io(), at(0), indexed(@intCast(i)))); + } + + const newcomer = indexed(max_clients); + try testing.expect(!fx.limiter.check(fx.io(), at(3600), newcomer).allowed); + try testing.expect(!fx.limiter.tryAcquireSse(fx.io(), at(3600), newcomer)); + try testing.expectEqual(@as(u32, max_clients), fx.limiter.trackedClients(fx.io())); + + // Releasing one stream makes exactly one slot reclaimable. + fx.limiter.releaseSse(fx.io(), indexed(0)); + try testing.expect(fx.limiter.check(fx.io(), at(3600), newcomer).allowed); + try testing.expectEqual(@as(u32, max_clients), fx.limiter.trackedClients(fx.io())); +} + +test "bucket arithmetic holds far from the timestamp origin" { + const fx = try Fixture.init(.{ .rate_per_min = 2, .localhost_exempt = false, .sse_max_per_ip = 1 }); + defer fx.deinit(); + + // Beyond the range of i64 nanoseconds, so only the i96 arithmetic works. + const base: i96 = 1 << 80; + const client = v4(10, 1, 2, 3); + + try testing.expect(fx.limiter.check(fx.io(), .{ .nanoseconds = base }, client).allowed); + try testing.expect(fx.limiter.check(fx.io(), .{ .nanoseconds = base + 1 }, client).allowed); + try testing.expect(!fx.limiter.check(fx.io(), .{ .nanoseconds = base + 2 }, client).allowed); + try testing.expect(fx.limiter.check(fx.io(), .{ .nanoseconds = base + window_ns / 2 }, client).allowed); + try testing.expectEqual(@as(u32, 1), fx.limiter.sweep(fx.io(), .{ .nanoseconds = base + 4 * window_ns })); +} + +test "a timestamp that goes backwards neither refills nor underflows" { + const fx = try Fixture.init(.{ .rate_per_min = 2, .localhost_exempt = false, .sse_max_per_ip = 1 }); + defer fx.deinit(); + + const client = v4(10, 4, 5, 6); + try testing.expect(fx.limiter.check(fx.io(), at(100), client).allowed); + try testing.expect(fx.limiter.check(fx.io(), at(100), client).allowed); + try testing.expect(!fx.limiter.check(fx.io(), at(90), client).allowed); + try testing.expect(!fx.limiter.check(fx.io(), at(100), client).allowed); +} + +fn initCheckDeinit(allocator: Allocator) !void { + var threaded: std.Io.Threaded = .init(allocator, .{}); + defer threaded.deinit(); + + var limiter = try ApiLimiter.init(allocator, .{ + .rate_per_min = 10, + .localhost_exempt = false, + .sse_max_per_ip = 3, + }); + defer limiter.deinit(); + try testing.expect(limiter.check(threaded.io(), at(0), v4(10, 0, 0, 1)).allowed); +} + +test "init surfaces allocation failure without leaking" { + try testing.checkAllAllocationFailures(testing.allocator, initCheckDeinit, .{}); +} diff --git a/src/web/auth.zig b/src/web/auth.zig new file mode 100644 index 0000000..e9785df --- /dev/null +++ b/src/web/auth.zig @@ -0,0 +1,778 @@ +//! Web authentication (PLAN §3.11, §12.1, §19; milestone-8 rulings 17, 18, 29). +//! +//! Two independent pieces: +//! +//! * `verifyPassword` checks an operator's password against the argon2id PHC +//! string in `web.password_hash`. The PHC string carries its own parameters, +//! so this file names none: a hash written by an older binary with different +//! parameters still verifies. +//! * `Sessions` is the in-memory session table. A successful login mints a +//! token, the browser carries it in a cookie, and every later request is +//! authenticated by that cookie alone. Nothing is persisted: a restart logs +//! every operator out, which is the behaviour a household admin UI wants and +//! costs no schema. +//! +//! The table is a fixed array of `max_sessions` slots, so no request path +//! allocates. A 33rd login evicts the least recently used session rather than +//! failing: an operator who can prove the password must always get in, and 32 +//! concurrent browsers is already far past household scale. +//! +//! Only the SHA-256 digest of a token is stored. A memory disclosure therefore +//! yields no usable cookie, and lookups compare digests with +//! `std.crypto.timing_safe.eql`, which needs fixed-size arrays (slices are not +//! accepted — `timing_safe.zig:12`). +//! +//! Secrets never reach a log line: no password, hash, token or cookie value is +//! formatted anywhere in this file (ruling 29). The login handler logs the +//! client address and the outcome, nothing else. + +const std = @import("std"); + +const model = @import("../config/model.zig"); + +const Allocator = std.mem.Allocator; +const Sha256 = std.crypto.hash.sha2.Sha256; +const base64 = std.base64.url_safe_no_pad; + +const log = std.log.scoped(.web_auth); + +/// Raw token length. 256 bits of `io.randomSecure` entropy. +pub const token_bytes = 32; + +/// Length of the cookie value: base64 (url-safe, unpadded) of `token_bytes`. +pub const cookie_value_len = base64.Encoder.calcSize(token_bytes); + +/// The cookie value as it appears on the wire. +pub const Cookie = [cookie_value_len]u8; + +pub const cookie_name = "nxdns_session"; + +/// `Secure` is deliberately absent: nxdns serves plain HTTP on the LAN and TLS +/// termination, where an operator wants it, belongs to their reverse proxy. +/// Setting `Secure` would make the cookie unusable in the supported deployment. +pub const cookie_attributes = "HttpOnly; SameSite=Lax; Path=/"; + +/// Longest password `verifyPassword` will hash. argon2id costs 19 MiB and a +/// deliberate delay per call, so an unbounded body must not reach it; a +/// passphrase longer than this is refused as if it were wrong. +pub const max_password_len = 256; + +/// Authentication is on exactly when a hash exists (ruling 17). An empty hash +/// is the documented "no password set" state, not a misconfiguration. +pub fn authEnabled(web: model.Web) bool { + return web.password_hash.len != 0; +} + +pub const Outcome = enum { + ok, + /// Wrong password, or a hash this build cannot verify. Both are the same + /// answer to the client. + denied, + /// Verification could not run (out of memory, unreadable PHC string). The + /// handler answers 500, never 401: a broken hash must not read as a wrong + /// password. + unavailable, +}; + +/// Verifies `password` against the PHC string in `password_hash`. +/// +/// `strVerify` requires both an allocator (argon2.zig:600) and an `Io` +/// (argon2.zig:619). It is slow by construction — the caller runs it on the +/// connection task, which is why the API limiter counts login attempts like any +/// other request. +pub fn verifyPassword( + io: std.Io, + gpa: Allocator, + password_hash: []const u8, + password: []const u8, +) std.Io.Cancelable!Outcome { + if (password_hash.len == 0) return .denied; + if (password.len == 0 or password.len > max_password_len) return .denied; + + std.crypto.pwhash.argon2.strVerify( + password_hash, + password, + .{ .allocator = gpa }, + io, + ) catch |err| switch (err) { + error.PasswordVerificationFailed => return .denied, + error.Canceled => return error.Canceled, + else => { + log.warn("verifying the web password failed: {s}", .{@errorName(err)}); + return .unavailable; + }, + }; + return .ok; +} + +/// The password hash the running server authenticates against. `WebState.web` +/// is the boot-time configuration and never changes, but `PUT /api/settings` +/// can replace the password while the process runs, and the revoked credential +/// must stop working before the next restart. The login path and the session +/// gate read this holder, never the boot value. +/// +/// A mutex-guarded copy-out rather than an atomic pointer swap: argon2 +/// verification holds the hash for tens of milliseconds, so a reader must not +/// borrow the stored slice across a replacement. Copying at most `max_len` +/// bytes under an uncontended mutex is cheap, and it lets `installAndRevoke` +/// free the old allocation immediately instead of deferring reclamation. +/// +/// Ownership: the boot value borrows the configuration arena and is never +/// freed here. `installAndRevoke` takes ownership of a gpa allocation and +/// frees the previous hash if this holder owned it; whoever owns the +/// `WebState` calls `deinit`, which frees the last installed one the same way. +pub const LiveHash = struct { + /// Lock order: this mutex is taken BEFORE `Sessions.mutex`, never after. + /// Two sites nest them: `confirmSession` holds it across + /// `Sessions.createWithToken`, and `installAndRevoke` holds it across + /// `Sessions.clearAll`. No code path may touch this holder while holding + /// the session table's mutex. + /// + /// The single direction is also the revocation argument: a confirm and an + /// `installAndRevoke` serialize on this mutex, so a confirm either + /// precedes the transition (the nested `clearAll` wipes the session it + /// just minted) or follows it (the snapshot's generation is stale and + /// nothing is minted). No interleaving exists in which a session minted + /// under the new password is killed by its own transition. + mutex: std.Io.Mutex = .init, + hash: []const u8 = "", + owned: bool = false, + /// Bumped by every `installAndRevoke`. A login snapshot carries the + /// generation it copied, and `confirmSession` refuses to mint a session + /// for a snapshot an install has since replaced. + generation: u64 = 0, + + /// Every PHC string nxdns produces fits: `config/import.zig` and the + /// settings handler both hash into a buffer of this size. + pub const max_len = 256; + + pub fn init(boot_hash: []const u8) LiveHash { + return .{ .hash = boot_hash }; + } + + /// Whether a password is set right now — ruling 17's gate, live. + pub fn enabled(self: *LiveHash, io: std.Io) bool { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + return self.hash.len != 0; + } + + /// What `copy` hands out: the hash to verify against and the generation + /// it was copied under, for `confirmSession` to check after the slow + /// verification. + pub const Snapshot = struct { + hash: []const u8, + generation: u64, + }; + + /// Copies the current hash into `buf`. `error.Oversize` means a stored + /// hash this holder cannot hand out — only a hand-edited database, never + /// a hash nxdns wrote — and the caller must fail closed as an internal + /// error, not as a wrong password. + pub fn copy(self: *LiveHash, io: std.Io, buf: *[max_len]u8) error{Oversize}!Snapshot { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + if (self.hash.len > buf.len) return error.Oversize; + @memcpy(buf[0..self.hash.len], self.hash); + return .{ .hash = buf[0..self.hash.len], .generation = self.generation }; + } + + /// Takes ownership of `new_hash`, which must be a `gpa` allocation, frees + /// the previous hash if this holder owned it, and ends every session in + /// `sessions` before releasing the mutex. The swap, the generation bump + /// and the revocation are one mutex-held operation on purpose: were the + /// mutex released between them, a login verified against the new hash + /// could confirm in the gap and the trailing `clearAll` would kill that + /// fresh, legitimate cookie. `sessions` is optional only because a server + /// can run without a session store; null skips the revocation, nothing + /// else. + pub fn installAndRevoke( + self: *LiveHash, + io: std.Io, + gpa: Allocator, + sessions: ?*Sessions, + new_hash: []const u8, + ) void { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + if (self.owned) gpa.free(self.hash); + self.hash = new_hash; + self.owned = true; + self.generation += 1; + if (sessions) |table| table.clearAll(io); + } + + /// Mints a session only when no `installAndRevoke` has replaced the hash + /// since the snapshot at `generation` was taken. Argon2 verification runs + /// on a copy outside any lock, so a settings PUT can replace the password + /// while a login is still verifying the old one; minting afterwards would + /// resurrect the revoked credential. The token bytes and the timestamp + /// are produced before the mutex is taken: `randomSecure` may stall on + /// entropy, and a stall inside this lock would block password installs + /// and every request's `enabled`/`copy` check. Under the ordered locks + /// only the generation check and the digest insert remain. An + /// `installAndRevoke` therefore lands either before this call (the + /// generation differs, null — the login is denied) or after it (its + /// nested `clearAll` ends the session just minted). Null always means + /// "the password changed under you", never an error. + pub fn confirmSession( + self: *LiveHash, + io: std.Io, + sessions: *Sessions, + generation: u64, + ) Sessions.CreateError!?Cookie { + var token: [token_bytes]u8 = undefined; + try std.Io.randomSecure(io, &token); + defer std.crypto.secureZero(u8, &token); + const now_s = nowSeconds(io); + + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + if (self.generation != generation) return null; + return sessions.createWithToken(io, token, now_s); + } + + pub fn deinit(self: *LiveHash, gpa: Allocator) void { + if (self.owned) gpa.free(self.hash); + self.* = undefined; + } +}; + +/// One live session. `last_used` drives the LRU eviction and moves on every +/// successful validation; `expires_at` is fixed at login, so a session ends at +/// its TTL however busy it was. +const Slot = struct { + used: bool, + digest: [Sha256.digest_length]u8, + expires_at: i64, + last_used: i64, +}; + +pub const Sessions = struct { + /// Concurrent connection tasks share one table, so every field below is + /// written under this mutex. + /// + /// `lockUncancelable` throughout: the critical sections are scans of 32 + /// slots with no I/O in them, and the callers are request handlers whose + /// cancellation should land on the socket, not inside the session table. + /// + /// Lock order: when held together with `LiveHash.mutex`, that mutex comes + /// first (`LiveHash.confirmSession` and `LiveHash.installAndRevoke` are + /// the sites that nest them). No code path may take `LiveHash.mutex` + /// while holding this one. + mutex: std.Io.Mutex, + slots: [max_sessions]Slot, + ttl_seconds: i64, + + pub const max_sessions = 32; + + pub const CreateError = std.Io.RandomSecureError; + + /// `ttl_hours` is `web.session_ttl_hours`; `validate.zig` rejects zero. + pub fn init(ttl_hours: u16) Sessions { + std.debug.assert(ttl_hours > 0); + return .{ + .mutex = .init, + .slots = @splat(.{ + .used = false, + .digest = @splat(0), + .expires_at = 0, + .last_used = 0, + }), + .ttl_seconds = @as(i64, ttl_hours) * 3600, + }; + } + + /// Mints a session from a caller-supplied token and clock and returns the + /// cookie value to send back; the table keeps only the token's digest. + /// `LiveHash.confirmSession` supplies real entropy gathered before any + /// lock; a test supplies fixed bytes and is deterministic without seeding + /// any global randomness. + pub fn createWithToken( + self: *Sessions, + io: std.Io, + token: [token_bytes]u8, + now_s: i64, + ) Cookie { + var digest: [Sha256.digest_length]u8 = undefined; + Sha256.hash(&token, &digest, .{}); + + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + self.sweepLocked(now_s); + const slot = self.freeSlotLocked() orelse self.lruSlotLocked(); + slot.* = .{ + .used = true, + .digest = digest, + .expires_at = now_s + self.ttl_seconds, + .last_used = now_s, + }; + + var cookie: Cookie = undefined; + const encoded = base64.Encoder.encode(&cookie, &token); + std.debug.assert(encoded.len == cookie.len); + return cookie; + } + + /// True when `cookie_value` names a live session, which it then touches. + /// Every malformed, unknown or expired value is the same `false`. + pub fn validate(self: *Sessions, io: std.Io, cookie_value: []const u8) bool { + return self.validateAt(io, cookie_value, nowSeconds(io)); + } + + pub fn validateAt(self: *Sessions, io: std.Io, cookie_value: []const u8, now_s: i64) bool { + const digest = digestOf(cookie_value) orelse return false; + + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + self.sweepLocked(now_s); + const slot = self.findLocked(digest) orelse return false; + slot.last_used = now_s; + return true; + } + + /// Drops the named session. True when one was dropped, which is what lets + /// the logout handler answer the same way for a stale cookie as for a live + /// one if it chooses to. + pub fn logout(self: *Sessions, io: std.Io, cookie_value: []const u8) bool { + const digest = digestOf(cookie_value) orelse return false; + + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + const slot = self.findLocked(digest) orelse return false; + slot.used = false; + slot.digest = @splat(0); + return true; + } + + /// Ends every session. `PUT /api/settings` calls this when it changes + /// `web.password_hash`: a password change must not leave the sessions it was + /// meant to revoke alive. + pub fn clearAll(self: *Sessions, io: std.Io) void { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + for (&self.slots) |*slot| { + slot.used = false; + slot.digest = @splat(0); + } + } + + /// Sessions that have not expired by `now_s`. Expired slots are reclaimed on + /// the way, so this is also the sweep the accessors perform. + pub fn count(self: *Sessions, io: std.Io, now_s: i64) u32 { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + self.sweepLocked(now_s); + var live: u32 = 0; + for (self.slots) |slot| { + if (slot.used) live += 1; + } + return live; + } + + fn findLocked(self: *Sessions, digest: [Sha256.digest_length]u8) ?*Slot { + var found: ?*Slot = null; + for (&self.slots) |*slot| { + if (!slot.used) continue; + // Every live slot is compared, so the work done does not depend on + // which one matches. + if (std.crypto.timing_safe.eql([Sha256.digest_length]u8, slot.digest, digest)) { + found = slot; + } + } + return found; + } + + fn sweepLocked(self: *Sessions, now_s: i64) void { + for (&self.slots) |*slot| { + if (slot.used and now_s >= slot.expires_at) { + slot.used = false; + slot.digest = @splat(0); + } + } + } + + fn freeSlotLocked(self: *Sessions) ?*Slot { + for (&self.slots) |*slot| { + if (!slot.used) return slot; + } + return null; + } + + /// The table is full, so the oldest session makes room. Ties go to the + /// lowest index; with 32 slots the choice among equally old sessions carries + /// no meaning. + fn lruSlotLocked(self: *Sessions) *Slot { + var oldest: *Slot = &self.slots[0]; + for (self.slots[1..]) |*slot| { + if (slot.last_used < oldest.last_used) oldest = slot; + } + return oldest; + } +}; + +/// Decodes a cookie value back to the token and hashes it. Null when the value +/// is not exactly one unpadded base64 encoding of `token_bytes` bytes. +fn digestOf(cookie_value: []const u8) ?[Sha256.digest_length]u8 { + if (cookie_value.len != cookie_value_len) return null; + const decoded_len = base64.Decoder.calcSizeForSlice(cookie_value) catch return null; + if (decoded_len != token_bytes) return null; + + var token: [token_bytes]u8 = undefined; + base64.Decoder.decode(&token, cookie_value) catch return null; + defer std.crypto.secureZero(u8, &token); + + var digest: [Sha256.digest_length]u8 = undefined; + Sha256.hash(&token, &digest, .{}); + return digest; +} + +/// Session lifetimes are wall-clock hours, so they follow the operator's clock +/// rather than the machine's uptime. +fn nowSeconds(io: std.Io) i64 { + return std.Io.Clock.real.now(io).toSeconds(); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +fn tokenOf(n: u8) [token_bytes]u8 { + return @splat(n); +} + +test "authEnabled follows the presence of a hash" { + try testing.expect(!authEnabled(.{})); + try testing.expect(!authEnabled(.{ .password_hash = "" })); + try testing.expect(authEnabled(.{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" })); +} + +test "a session created with a known token validates through its cookie value" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var sessions: Sessions = .init(24); + const cookie = sessions.createWithToken(io, tokenOf(7), 1_700_000_000); + + try testing.expectEqual(@as(usize, 43), cookie.len); + for (cookie) |c| { + try testing.expect(std.ascii.isAlphanumeric(c) or c == '-' or c == '_'); + } + + try testing.expect(sessions.validateAt(io, &cookie, 1_700_000_001)); + try testing.expectEqual(@as(u32, 1), sessions.count(io, 1_700_000_001)); + + // The cookie value carries the token, so an independent encoding of the + // same token is the same session. + var expected: Cookie = undefined; + _ = base64.Encoder.encode(&expected, &tokenOf(7)); + try testing.expectEqualStrings(&expected, &cookie); +} + +test "confirmSession with real entropy yields a validating cookie" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var live: LiveHash = .init("boot-hash"); + defer live.deinit(testing.allocator); + var sessions: Sessions = .init(24); + + const cookie = (try live.confirmSession(io, &sessions, 0)).?; + try testing.expect(sessions.validate(io, &cookie)); + + const second = (try live.confirmSession(io, &sessions, 0)).?; + try testing.expect(!std.mem.eql(u8, &cookie, &second)); + try testing.expect(sessions.validate(io, &cookie)); + try testing.expect(sessions.validate(io, &second)); +} + +test "a wrong token of the right length is rejected" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var sessions: Sessions = .init(24); + const cookie = sessions.createWithToken(io, tokenOf(1), 1_000); + + var other: Cookie = undefined; + _ = base64.Encoder.encode(&other, &tokenOf(2)); + try testing.expectEqual(cookie.len, other.len); + try testing.expect(!sessions.validateAt(io, &other, 1_000)); + + // One flipped character of a live cookie is not that session either. + var tampered = cookie; + tampered[0] = if (tampered[0] == 'A') 'B' else 'A'; + try testing.expect(!sessions.validateAt(io, &tampered, 1_000)); + + try testing.expect(sessions.validateAt(io, &cookie, 1_000)); +} + +test "malformed cookie values are rejected without touching the table" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var sessions: Sessions = .init(24); + _ = sessions.createWithToken(io, tokenOf(3), 1_000); + + try testing.expect(!sessions.validateAt(io, "", 1_000)); + try testing.expect(!sessions.validateAt(io, "short", 1_000)); + // 43 characters, one of them outside the url-safe alphabet. + try testing.expect(!sessions.validateAt(io, "*" ** 43, 1_000)); + // The padded encoding is the right token but the wrong length. + var padded: [44]u8 = undefined; + _ = std.base64.url_safe.Encoder.encode(&padded, &tokenOf(3)); + try testing.expect(!sessions.validateAt(io, &padded, 1_000)); + + try testing.expectEqual(@as(u32, 1), sessions.count(io, 1_000)); +} + +test "a session expires at its ttl and frees its slot" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var sessions: Sessions = .init(2); + const cookie = sessions.createWithToken(io, tokenOf(9), 0); + + try testing.expect(sessions.validateAt(io, &cookie, 7199)); + // Use does not extend the lifetime. + try testing.expect(!sessions.validateAt(io, &cookie, 7200)); + try testing.expectEqual(@as(u32, 0), sessions.count(io, 7200)); +} + +test "the thirty-third session evicts the least recently used one" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var sessions: Sessions = .init(24); + var cookies: [Sessions.max_sessions]Cookie = undefined; + for (&cookies, 0..) |*cookie, i| { + cookie.* = sessions.createWithToken(io, tokenOf(@intCast(i)), 1_000 + @as(i64, @intCast(i))); + } + try testing.expectEqual(@as(u32, Sessions.max_sessions), sessions.count(io, 2_000)); + + // Touching the oldest session makes a later one the eviction candidate. + try testing.expect(sessions.validateAt(io, &cookies[0], 2_000)); + + const newcomer = sessions.createWithToken(io, tokenOf(200), 2_001); + try testing.expectEqual(@as(u32, Sessions.max_sessions), sessions.count(io, 2_001)); + try testing.expect(sessions.validateAt(io, &newcomer, 2_001)); + try testing.expect(sessions.validateAt(io, &cookies[0], 2_001)); + try testing.expect(!sessions.validateAt(io, &cookies[1], 2_001)); + for (cookies[2..]) |cookie| { + try testing.expect(sessions.validateAt(io, &cookie, 2_001)); + } +} + +test "an expired slot is reused before any live session is evicted" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var sessions: Sessions = .init(1); + var cookies: [Sessions.max_sessions]Cookie = undefined; + for (&cookies, 0..) |*cookie, i| { + cookie.* = sessions.createWithToken(io, tokenOf(@intCast(i)), @intCast(i)); + } + // The first session expires an hour after it was made; the rest are younger. + const newcomer = sessions.createWithToken(io, tokenOf(100), 3_600); + try testing.expect(!sessions.validateAt(io, &cookies[0], 3_600)); + try testing.expect(sessions.validateAt(io, &cookies[1], 3_600)); + try testing.expect(sessions.validateAt(io, &newcomer, 3_600)); +} + +test "logout drops one session and leaves the others" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var sessions: Sessions = .init(24); + const first = sessions.createWithToken(io, tokenOf(1), 1_000); + const second = sessions.createWithToken(io, tokenOf(2), 1_000); + + try testing.expect(sessions.logout(io, &first)); + try testing.expect(!sessions.validateAt(io, &first, 1_000)); + try testing.expect(sessions.validateAt(io, &second, 1_000)); + + // Logging the same cookie out twice is not an error, just no longer a hit. + try testing.expect(!sessions.logout(io, &first)); + try testing.expect(!sessions.logout(io, "nonsense")); + try testing.expectEqual(@as(u32, 1), sessions.count(io, 1_000)); + + // The freed slot is available again. + const third = sessions.createWithToken(io, tokenOf(3), 1_001); + try testing.expect(sessions.validateAt(io, &third, 1_001)); + try testing.expectEqual(@as(u32, 2), sessions.count(io, 1_001)); +} + +test "clearAll ends every session" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var sessions: Sessions = .init(24); + var cookies: [4]Cookie = undefined; + for (&cookies, 0..) |*cookie, i| { + cookie.* = sessions.createWithToken(io, tokenOf(@intCast(i)), 1_000); + } + + sessions.clearAll(io); + try testing.expectEqual(@as(u32, 0), sessions.count(io, 1_000)); + for (cookies) |cookie| { + try testing.expect(!sessions.validateAt(io, &cookie, 1_000)); + } + + // The store keeps working after a clear. + const fresh = sessions.createWithToken(io, tokenOf(9), 1_001); + try testing.expect(sessions.validateAt(io, &fresh, 1_001)); +} + +test "the live hash starts as the boot value and follows installAndRevoke" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + const gpa = testing.allocator; + + var live: LiveHash = .init("boot-hash"); + defer live.deinit(gpa); + try testing.expect(live.enabled(io)); + + var buf: [LiveHash.max_len]u8 = undefined; + const boot = try live.copy(io, &buf); + try testing.expectEqualStrings("boot-hash", boot.hash); + try testing.expectEqual(@as(u64, 0), boot.generation); + + // The boot value is borrowed; the first install must not free it. With no + // session store the revocation half is skipped. + live.installAndRevoke(io, gpa, null, try gpa.dupe(u8, "first-replacement")); + const first = try live.copy(io, &buf); + try testing.expectEqualStrings("first-replacement", first.hash); + try testing.expectEqual(@as(u64, 1), first.generation); + + // The second install frees the first — the leak detector is the assertion. + live.installAndRevoke(io, gpa, null, try gpa.dupe(u8, "second-replacement")); + const second = try live.copy(io, &buf); + try testing.expectEqualStrings("second-replacement", second.hash); + try testing.expectEqual(@as(u64, 2), second.generation); +} + +test "an empty live hash reads as authentication off" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var live: LiveHash = .{}; + defer live.deinit(testing.allocator); + try testing.expect(!live.enabled(io)); + + var buf: [LiveHash.max_len]u8 = undefined; + try testing.expectEqual(@as(usize, 0), (try live.copy(io, &buf)).hash.len); +} + +test "confirmSession mints for the copied generation and refuses a stale one" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + const gpa = testing.allocator; + + var live: LiveHash = .init("boot-hash"); + defer live.deinit(gpa); + var sessions: Sessions = .init(24); + + var buf: [LiveHash.max_len]u8 = undefined; + const snapshot = try live.copy(io, &buf); + + const cookie = (try live.confirmSession(io, &sessions, snapshot.generation)).?; + try testing.expect(sessions.validate(io, &cookie)); + + // An install between copy and confirm makes the snapshot stale: no + // session, and the ones the install revoked stay revoked. + live.installAndRevoke(io, gpa, &sessions, try gpa.dupe(u8, "new-hash")); + try testing.expectEqual(@as(?Cookie, null), try live.confirmSession(io, &sessions, snapshot.generation)); + try testing.expectEqual(@as(u32, 0), sessions.count(io, 0)); + + // A snapshot of the new hash confirms again. + const fresh = try live.copy(io, &buf); + try testing.expect(try live.confirmSession(io, &sessions, fresh.generation) != null); +} + +test "a session confirmed before installAndRevoke does not survive it" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + const gpa = testing.allocator; + + var live: LiveHash = .init("boot-hash"); + defer live.deinit(gpa); + var sessions: Sessions = .init(24); + + var buf: [LiveHash.max_len]u8 = undefined; + const snapshot = try live.copy(io, &buf); + const cookie = (try live.confirmSession(io, &sessions, snapshot.generation)).?; + try testing.expect(sessions.validate(io, &cookie)); + + // The transition lands after the confirm: the nested clearAll ends the + // session just minted, so the ordering leaves no cookie alive either way. + live.installAndRevoke(io, gpa, &sessions, try gpa.dupe(u8, "new-hash")); + try testing.expect(!sessions.validate(io, &cookie)); + try testing.expectEqual(@as(u32, 0), sessions.count(io, 0)); +} + +test "a boot hash too long to copy is reported, not truncated" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var live: LiveHash = .init("x" ** (LiveHash.max_len + 1)); + defer live.deinit(testing.allocator); + try testing.expect(live.enabled(io)); + + var buf: [LiveHash.max_len]u8 = undefined; + try testing.expectError(error.Oversize, live.copy(io, &buf)); +} + +test "verifyPassword accepts the password behind an import-path hash" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + const gpa = testing.allocator; + + // The same parameters `config/import.zig` hashes with (owasp_2id, PHC). + var buf: [256]u8 = undefined; + const hash = try std.crypto.pwhash.argon2.strHash("correct horse battery staple", .{ + .allocator = gpa, + .params = .owasp_2id, + .mode = .argon2id, + .encoding = .phc, + }, &buf, io); + try testing.expect(std.mem.startsWith(u8, hash, "$argon2id$")); + + try testing.expectEqual(Outcome.ok, try verifyPassword(io, gpa, hash, "correct horse battery staple")); + try testing.expectEqual(Outcome.denied, try verifyPassword(io, gpa, hash, "correct horse battery stapl")); + try testing.expectEqual(Outcome.denied, try verifyPassword(io, gpa, hash, "")); + try testing.expectEqual(Outcome.denied, try verifyPassword(io, gpa, hash, "x" ** (max_password_len + 1))); +} + +test "verifyPassword denies with no hash and reports an unreadable one" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + const gpa = testing.allocator; + + try testing.expectEqual(Outcome.denied, try verifyPassword(io, gpa, "", "anything")); + try testing.expectEqual(Outcome.unavailable, try verifyPassword(io, gpa, "not a phc string", "anything")); +} diff --git a/src/web/handlers/auth.zig b/src/web/handlers/auth.zig new file mode 100644 index 0000000..065af05 --- /dev/null +++ b/src/web/handlers/auth.zig @@ -0,0 +1,382 @@ +//! `/api/auth/login` and `/api/auth/logout` (rulings 17 and 18). +//! +//! Login is the one route that is always reachable without a session, and the +//! one that must never help a guess along: a wrong password and an unknown one +//! are the same 401, and the only thing this file logs is the client address +//! and the outcome. No password, no hash, no token and no cookie value is ever +//! formatted anywhere here (ruling 29). +//! +//! A hash this build cannot read is a 500, not a 401. Answering 401 would tell +//! an operator with a corrupted `web.password_hash` that their password is +//! wrong, and they would go on retyping a password that can never verify. +//! +//! With no password set, authentication is off and every route is already open, +//! so a login attempt succeeds without minting anything: the answer says +//! `auth_required: false` and carries no cookie, because a session that +//! authorises nothing would be a lie the browser stores. + +const std = @import("std"); + +const auth = @import("../auth.zig"); +const http_util = @import("../http_util.zig"); +const model = @import("../../config/model.zig"); +const mutations = @import("mutations.zig"); +const server = @import("../server.zig"); + +const Failure = mutations.Failure; +const Request = http_util.Request; +const HandlerError = http_util.HandlerError; + +const log = std.log.scoped(.web_auth); + +const LoginBody = struct { + password: []const u8, +}; + +/// Long enough for the cookie plus its attributes and a `Max-Age`. +const cookie_buf_len = 192; + +pub const Login = union(enum) { + /// A session was minted; the value is the cookie to set. + cookie: auth.Cookie, + /// No password is configured, so there is nothing to log in to. + no_auth, + fail: Failure, +}; + +/// Verifies and, on success, mints a session (ruling 17). +pub fn applyLogin(state: *server.WebState, io: std.Io, password: []const u8) Login { + // The live hash, never `state.web.password_hash`: a settings PUT may have + // replaced the password since boot, and the revoked one must stop minting + // sessions immediately. An unreadable stored hash is a 500, not a 401, + // for the same reason a broken PHC string is. + var hash_buf: [auth.LiveHash.max_len]u8 = undefined; + const snapshot = state.live_hash.copy(io, &hash_buf) catch + return .{ .fail = .{ .internal = error.Unexpected } }; + if (snapshot.hash.len == 0) return .no_auth; + + const sessions = state.sessions orelse + return .{ .fail = .{ .unavailable = "no session store" } }; + + const outcome = auth.verifyPassword(io, state.gpa, snapshot.hash, password) catch + return .{ .fail = .{ .unavailable = "shutting down" } }; + + switch (outcome) { + .denied => return .{ .fail = .{ .invalid = "invalid password" } }, + .unavailable => return .{ .fail = .{ .internal = error.Unexpected } }, + .ok => {}, + } + + return confirmLogin(state, io, sessions, snapshot.generation); +} + +/// The step after a successful verification, separated so a test can install +/// a replacement hash between verify and confirm. Verification ran against a +/// copy, outside any lock: a settings PUT may have installed a new hash and +/// cleared every session in the meantime, and minting for the old hash then +/// would hand the revoked password a live session. `confirmSession` answers +/// null exactly in that case, and the login is denied the same way a wrong +/// password is — the operator retries with the password that now applies. +fn confirmLogin( + state: *server.WebState, + io: std.Io, + sessions: *auth.Sessions, + generation: u64, +) Login { + const cookie = state.live_hash.confirmSession(io, sessions, generation) catch + return .{ .fail = .{ .internal = error.Unexpected } }; + if (cookie) |value| return .{ .cookie = value }; + return .{ .fail = .{ .invalid = "invalid password" } }; +} + +/// Ends the session the cookie names. An unknown cookie is not an error: the +/// point of logging out is to end up logged out, which is where it already is. +pub fn applyLogout(state: *server.WebState, io: std.Io, cookie_header: []const u8) bool { + const sessions = state.sessions orelse return false; + const value = http_util.cookieValue(cookie_header, auth.cookie_name) orelse return false; + return sessions.logout(io, value); +} + +// --------------------------------------------------------------------------- +// routes +// --------------------------------------------------------------------------- + +pub fn login(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const parsed = http_util.parseBody(LoginBody, request) catch |err| + return mutations.respondBadBody(request, err); + + switch (applyLogin(state, io, parsed.value.password)) { + .no_auth => { + return http_util.respondJson(request, .ok, .{ + .authenticated = true, + .auth_required = false, + }, &.{}); + }, + .fail => |failure| { + // 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. + if (failure == .invalid) { + log.warn("web login refused for {f}", .{request.peer}); + return http_util.respondError(request, .unauthorized, "invalid password"); + } + return mutations.respondFailure(request, failure, "verifying the web password"); + }, + .cookie => |cookie| { + log.info("web login accepted for {f}", .{request.peer}); + var buf: [cookie_buf_len]u8 = undefined; + const header = http_util.formatSetCookie( + &buf, + auth.cookie_name, + &cookie, + model.sessionTtlSeconds(state.web), + ) catch return error.OutOfMemory; + return http_util.respondJson(request, .ok, .{ + .authenticated = true, + .auth_required = true, + }, &.{.{ .name = "set-cookie", .value = header }}); + }, + } +} + +pub fn logout(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + _ = applyLogout(state, io, request.cookie); + + var buf: [cookie_buf_len]u8 = undefined; + const header = http_util.formatSetCookie(&buf, auth.cookie_name, "", 0) catch + return error.OutOfMemory; + + return http_util.respondJson( + request, + .ok, + .{ .authenticated = false }, + &.{.{ .name = "set-cookie", .value = header }}, + ); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +const TestIo = struct { + threaded: std.Io.Threaded, + + fn init() TestIo { + return .{ .threaded = .init(testing.allocator, .{}) }; + } + + fn io(self: *TestIo) std.Io { + return self.threaded.io(); + } + + fn deinit(self: *TestIo) void { + self.threaded.deinit(); + } +}; + +/// Hashes `password` the way `PUT /api/settings` does, so the login tests +/// verify against a hash this build actually produced. +fn hashOf(io: std.Io, buf: []u8, password: []const u8) ![]const u8 { + return std.crypto.pwhash.argon2.strHash(password, .{ + .allocator = testing.allocator, + .params = .owasp_2id, + .mode = .argon2id, + .encoding = .phc, + }, buf, io); +} + +test "with no password set, a login succeeds without minting a session" { + var t: TestIo = .init(); + defer t.deinit(); + + var sessions: auth.Sessions = .init(24); + var state: server.WebState = .{ .gpa = testing.allocator, .sessions = &sessions }; + + try testing.expectEqual(Login.no_auth, applyLogin(&state, t.io(), "anything")); + try testing.expectEqual(@as(u32, 0), sessions.count(t.io(), 0)); +} + +test "the right password mints a session the cookie then validates" { + var t: TestIo = .init(); + defer t.deinit(); + + var buf: [256]u8 = undefined; + const hash = try hashOf(t.io(), &buf, "hunter2"); + + var sessions: auth.Sessions = .init(24); + var state: server.WebState = .{ + .gpa = testing.allocator, + .live_hash = .init(hash), + .sessions = &sessions, + }; + + const outcome = applyLogin(&state, t.io(), "hunter2"); + try testing.expect(sessions.validate(t.io(), &outcome.cookie)); +} + +test "the wrong password is refused and mints nothing" { + var t: TestIo = .init(); + defer t.deinit(); + + var buf: [256]u8 = undefined; + const hash = try hashOf(t.io(), &buf, "hunter2"); + + var sessions: auth.Sessions = .init(24); + var state: server.WebState = .{ + .gpa = testing.allocator, + .live_hash = .init(hash), + .sessions = &sessions, + }; + + const outcome = applyLogin(&state, t.io(), "hunter3"); + try testing.expect(outcome.fail == .invalid); + try testing.expectEqual(@as(u32, 0), sessions.count(t.io(), 0)); + + // An empty password is refused without reaching argon2 at all. + try testing.expect(applyLogin(&state, t.io(), "").fail == .invalid); +} + +test "a password change between verify and confirm denies the login" { + var t: TestIo = .init(); + defer t.deinit(); + const gpa = testing.allocator; + + var buf: [256]u8 = undefined; + const hash = try hashOf(t.io(), &buf, "hunter2"); + + var sessions: auth.Sessions = .init(24); + var state: server.WebState = .{ + .gpa = gpa, + .live_hash = .init(hash), + .sessions = &sessions, + }; + defer state.live_hash.deinit(gpa); + + // The login path up to and including verification, as applyLogin runs it. + var hash_buf: [auth.LiveHash.max_len]u8 = undefined; + const snapshot = try state.live_hash.copy(t.io(), &hash_buf); + try testing.expectEqual( + auth.Outcome.ok, + try auth.verifyPassword(t.io(), gpa, snapshot.hash, "hunter2"), + ); + + // A settings PUT lands while argon2 was grinding: new hash in, every + // session out, one operation. + state.live_hash.installAndRevoke( + t.io(), + gpa, + &sessions, + try gpa.dupe(u8, "$argon2id$v=19$m=19456,t=2,p=1$a$b"), + ); + + // The confirm step must not mint from the revoked password. + const outcome = confirmLogin(&state, t.io(), &sessions, snapshot.generation); + try testing.expect(outcome.fail == .invalid); + try testing.expectEqual(@as(u32, 0), sessions.count(t.io(), 0)); +} + +test "a login confirmed before the password transition does not survive it" { + var t: TestIo = .init(); + defer t.deinit(); + const gpa = testing.allocator; + + var buf: [256]u8 = undefined; + const hash = try hashOf(t.io(), &buf, "hunter2"); + + var sessions: auth.Sessions = .init(24); + var state: server.WebState = .{ + .gpa = gpa, + .live_hash = .init(hash), + .sessions = &sessions, + }; + defer state.live_hash.deinit(gpa); + + var hash_buf: [auth.LiveHash.max_len]u8 = undefined; + const snapshot = try state.live_hash.copy(t.io(), &hash_buf); + const outcome = confirmLogin(&state, t.io(), &sessions, snapshot.generation); + try testing.expect(sessions.validate(t.io(), &outcome.cookie)); + + // The settings PUT lands after the confirm: the revocation nested in the + // transition ends the session it just minted. + state.live_hash.installAndRevoke( + t.io(), + gpa, + &sessions, + try gpa.dupe(u8, "$argon2id$v=19$m=19456,t=2,p=1$a$b"), + ); + try testing.expect(!sessions.validate(t.io(), &outcome.cookie)); + try testing.expectEqual(@as(u32, 0), sessions.count(t.io(), 0)); +} + +test "confirmLogin mints when no install intervened" { + var t: TestIo = .init(); + defer t.deinit(); + + var buf: [256]u8 = undefined; + const hash = try hashOf(t.io(), &buf, "hunter2"); + + var sessions: auth.Sessions = .init(24); + var state: server.WebState = .{ + .gpa = testing.allocator, + .live_hash = .init(hash), + .sessions = &sessions, + }; + + var hash_buf: [auth.LiveHash.max_len]u8 = undefined; + const snapshot = try state.live_hash.copy(t.io(), &hash_buf); + const outcome = confirmLogin(&state, t.io(), &sessions, snapshot.generation); + try testing.expect(sessions.validate(t.io(), &outcome.cookie)); +} + +test "a hash this build cannot read is a 500, not a refusal" { + var t: TestIo = .init(); + defer t.deinit(); + + var sessions: auth.Sessions = .init(24); + var state: server.WebState = .{ + .gpa = testing.allocator, + .live_hash = .init("$argon2id$not a phc string"), + .sessions = &sessions, + }; + + const outcome = applyLogin(&state, t.io(), "hunter2"); + try testing.expectEqual(auth.Outcome.unavailable, try auth.verifyPassword( + t.io(), + testing.allocator, + "$argon2id$not a phc string", + "hunter2", + )); + try testing.expect(outcome.fail == .internal); +} + +test "a password set with no session store refuses rather than opens" { + var t: TestIo = .init(); + defer t.deinit(); + + var state: server.WebState = .{ + .gpa = testing.allocator, + .live_hash = .init("$argon2id$v=19$m=19456,t=2,p=1$a$b"), + }; + + try testing.expect(applyLogin(&state, t.io(), "hunter2").fail == .unavailable); +} + +test "logging out ends the session the cookie names" { + var t: TestIo = .init(); + defer t.deinit(); + + var sessions: auth.Sessions = .init(24); + var state: server.WebState = .{ .gpa = testing.allocator, .sessions = &sessions }; + + const cookie = sessions.createWithToken(t.io(), @splat(3), 1_000); + var header_buf: [128]u8 = undefined; + const header = try std.fmt.bufPrint(&header_buf, "{s}={s}", .{ auth.cookie_name, &cookie }); + + try testing.expect(applyLogout(&state, t.io(), header)); + try testing.expect(!sessions.validateAt(t.io(), &cookie, 1_001)); + + // Logging out twice, or with no cookie at all, is not an error. + try testing.expect(!applyLogout(&state, t.io(), header)); + try testing.expect(!applyLogout(&state, t.io(), "")); +} diff --git a/src/web/handlers/blocklists.zig b/src/web/handlers/blocklists.zig new file mode 100644 index 0000000..9359e7e --- /dev/null +++ b/src/web/handlers/blocklists.zig @@ -0,0 +1,396 @@ +//! `/api/blocklists` — the blocklist sources table, and the manual refresh. +//! +//! The resource is `blocklist_sources`: its four configuration columns are what +//! an operator edits, and the counters the refresh writes ride along in the +//! read shape so the UI can show a list's size next to its url (ruling 9). +//! +//! `POST /api/blocklists/update` runs `Manager.refreshAll` and then the reload +//! seam, and answers 202 with the status of every source (ruling 12). The +//! refresh downloads and compiles before the response is written: 202 is +//! "accepted and done as far as this connection is concerned", and the status +//! table in the body is what tells the operator which sources actually landed. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const http_util = @import("../http_util.zig"); +const manager_mod = @import("../../filter/manager.zig"); +const model = @import("../../config/model.zig"); +const mutations = @import("mutations.zig"); +const server = @import("../server.zig"); +const sources_repo = @import("../../storage/repositories/sources_repo.zig"); + +const Failure = mutations.Failure; +const Request = http_util.Request; +const HandlerError = http_util.HandlerError; + +const log = std.log.scoped(.web_api); + +const url_conflict = "a blocklist with that url already exists"; + +/// How many source statuses one refresh response carries. A household runs a +/// handful of lists; a table longer than this is truncated in the response +/// only, never in the refresh. +pub const max_statuses = 64; + +const Body = struct { + url: []const u8, + name: []const u8, + enabled: bool = true, + is_suggested: bool = false, +}; + +const Created = union(enum) { id: i64, fail: Failure }; + +/// One source's status, in the shape the API speaks: the fixed-size text fields +/// of `manager.SourceStatus` become plain strings, and the compile counts are +/// flattened next to them. +pub const StatusView = struct { + id: i64, + state: []const u8, + loaded: bool, + last_attempt: i64, + last_success: i64, + url: []const u8, + last_error: []const u8, + domains: u32, + wildcards: u32, + skipped_regex: u32, + + pub fn from(status: *const manager_mod.SourceStatus) StatusView { + return .{ + .id = status.id, + .state = @tagName(status.state), + .loaded = status.loaded, + .last_attempt = status.last_attempt, + .last_success = status.last_success, + .url = status.urlText(), + .last_error = status.errorText(), + .domains = status.counts.domains, + .wildcards = status.counts.wildcards, + .skipped_regex = status.counts.skipped_regex, + }; + } +}; + +// --------------------------------------------------------------------------- +// decisions +// --------------------------------------------------------------------------- + +pub fn applyCreate( + state: *server.WebState, + io: std.Io, + arena: Allocator, + item: model.BlocklistSource, +) error{OutOfMemory}!Created { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return .{ .fail = failure }, + }; + if (try mutations.checkSource(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } }; + + state.config_lock.lockUncancelable(io); + const inserted = sources_repo.insertSourceRow(database, item); + state.config_lock.unlock(io); + + const id = inserted catch |err| return .{ .fail = mutations.dbFailure(err, url_conflict) }; + if (mutations.reload(state, io)) |failure| return .{ .fail = failure }; + return .{ .id = id }; +} + +pub fn applyUpdate( + state: *server.WebState, + io: std.Io, + arena: Allocator, + id: i64, + item: model.BlocklistSource, +) error{OutOfMemory}!?Failure { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return failure, + }; + if (try mutations.checkSource(arena, item)) |problem| return .{ .invalid = problem }; + + state.config_lock.lockUncancelable(io); + const written = sources_repo.updateSource(database, id, item); + state.config_lock.unlock(io); + + written catch |err| return mutations.dbFailure(err, url_conflict); + return mutations.reload(state, io); +} + +pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return failure, + }; + + state.config_lock.lockUncancelable(io); + const written = sources_repo.deleteSource(database, id); + state.config_lock.unlock(io); + + written catch |err| return mutations.dbFailure(err, url_conflict); + return mutations.reload(state, io); +} + +/// Refreshes every enabled source, then applies the result (ruling 12). +/// +/// `refreshAll` already ends in the manager's own reload; the seam is called +/// too, because it is how the composition root learns that a change landed and +/// the only reload a test can observe. +pub fn applyRefresh(state: *server.WebState, io: std.Io, out: []manager_mod.SourceStatus) union(enum) { + statuses: usize, + fail: Failure, +} { + const manager = state.manager orelse return .{ .fail = .{ .unavailable = "no blocklist manager" } }; + + manager.refreshAll(io) catch |err| switch (err) { + error.Canceled => return .{ .fail = .{ .unavailable = "shutting down" } }, + error.OutOfMemory => return .{ .fail = .{ .internal = error.OutOfMemory } }, + // A source that fails to fetch or compile records that in the status + // table and returns cleanly, so reaching here means the pass itself + // broke. `Manager.Error` is wider than `db.Error`, so the cause is + // logged here and the client is told only that it was internal. + else => { + log.warn("refreshing the blocklists failed: {s}", .{@errorName(err)}); + return .{ .fail = .{ .internal = error.Unexpected } }; + }, + }; + if (mutations.reload(state, io)) |failure| return .{ .fail = failure }; + return .{ .statuses = manager.statusSnapshot(io, out) }; +} + +// --------------------------------------------------------------------------- +// routes +// --------------------------------------------------------------------------- + +pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + _ = io; + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return mutations.respondFailure(request, failure, "listing blocklists"), + }; + + const rows = sources_repo.listSourceRows(database, request.arena) catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "listing blocklists"); + + return http_util.respondJson(request, .ok, .{ .blocklists = rows.items }, &.{}); +} + +pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + _ = io; + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return mutations.respondFailure(request, failure, "reading a blocklist"), + }; + + const row = sources_repo.getSource(database, request.arena, request.id.?) catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "reading a blocklist"); + const found = row orelse return mutations.respondFailure(request, .not_found, ""); + + return http_util.respondJson(request, .ok, found, &.{}); +} + +pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const parsed = http_util.parseBody(Body, request) catch |err| + return mutations.respondBadBody(request, err); + const item = toModel(parsed.value); + + return switch (try applyCreate(state, io, request.arena, item)) { + .fail => |failure| mutations.respondFailure(request, failure, "creating a blocklist"), + .id => |id| http_util.respondJson(request, .created, .{ + .id = id, + .url = item.url, + .name = item.name, + .enabled = item.enabled, + .is_suggested = item.is_suggested, + }, &.{}), + }; +} + +pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const parsed = http_util.parseBody(Body, request) catch |err| + return mutations.respondBadBody(request, err); + const item = toModel(parsed.value); + const id = request.id.?; + + if (try applyUpdate(state, io, request.arena, id, item)) |failure| { + return mutations.respondFailure(request, failure, "updating a blocklist"); + } + return http_util.respondJson(request, .ok, .{ + .id = id, + .url = item.url, + .name = item.name, + .enabled = item.enabled, + .is_suggested = item.is_suggested, + }, &.{}); +} + +pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + if (applyDelete(state, io, request.id.?)) |failure| { + return mutations.respondFailure(request, failure, "deleting a blocklist"); + } + return http_util.respondEmpty(request, .no_content); +} + +/// `POST /api/blocklists/update`. +pub fn refresh(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const statuses = try request.arena.alloc(manager_mod.SourceStatus, max_statuses); + + return switch (applyRefresh(state, io, statuses)) { + .fail => |failure| mutations.respondFailure(request, failure, "refreshing the blocklists"), + .statuses => |count| respondStatuses(request, statuses[0..count]), + }; +} + +fn respondStatuses(request: *Request, statuses: []const manager_mod.SourceStatus) HandlerError!void { + const views = try request.arena.alloc(StatusView, statuses.len); + for (views, statuses) |*view, *status| view.* = .from(status); + return http_util.respondJson(request, .accepted, .{ .sources = views }, &.{}); +} + +fn toModel(body: Body) model.BlocklistSource { + return .{ + .url = body.url, + .name = body.name, + .enabled = body.enabled, + .is_suggested = body.is_suggested, + }; +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +const valid: model.BlocklistSource = .{ .url = "https://a.test/list.txt", .name = "a" }; + +test "a created blocklist is stored with its runtime columns at their defaults" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreate(&bench.state, bench.io(), bench.arena(), valid); + try testing.expectEqual(@as(usize, 1), bench.reloads); + + const row = (try sources_repo.getSource(&bench.database, bench.arena(), created.id)).?; + try testing.expectEqualStrings("https://a.test/list.txt", row.url); + try testing.expect(row.enabled); + try testing.expectEqual(@as(?i64, null), row.last_updated); + try testing.expectEqual(@as(i64, 0), row.domain_count); +} + +test "a url the validator refuses never reaches the database" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ + .url = "ftp://a.test/list.txt", + .name = "a", + }); + try testing.expect(created.fail == .invalid); + try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM blocklist_sources")); + + const unnamed = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ + .url = "https://a.test/list.txt", + .name = "", + }); + try testing.expect(unnamed.fail == .invalid); + try testing.expectEqual(@as(usize, 0), bench.reloads); +} + +test "a duplicate url is a conflict" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + _ = try applyCreate(&bench.state, bench.io(), bench.arena(), valid); + const again = try applyCreate(&bench.state, bench.io(), bench.arena(), valid); + try testing.expectEqualStrings(url_conflict, again.fail.conflict); +} + +test "editing a blocklist keeps the counters the refresh wrote" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreate(&bench.state, bench.io(), bench.arena(), valid); + try sources_repo.updateSourceStats(&bench.database, created.id, .{ + .last_updated = 1700, + .domain_count = 42, + .wildcard_count = 3, + .skipped_regex_count = 1, + .checksum = "abc", + }); + + const failure = try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, .{ + .url = "https://a.test/list.txt", + .name = "renamed", + .enabled = false, + }); + try testing.expectEqual(@as(?Failure, null), failure); + + const row = (try sources_repo.getSource(&bench.database, bench.arena(), created.id)).?; + try testing.expectEqualStrings("renamed", row.name); + try testing.expect(!row.enabled); + try testing.expectEqual(@as(i64, 42), row.domain_count); + try testing.expectEqual(@as(usize, 2), bench.reloads); +} + +test "updating and deleting an id no row holds is a 404" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + try testing.expectEqual( + Failure.not_found, + (try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, valid)).?, + ); + try testing.expectEqual(Failure.not_found, applyDelete(&bench.state, bench.io(), 999).?); + try testing.expectEqual(@as(usize, 0), bench.reloads); +} + +test "deleting a blocklist takes its group assignments with it" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreate(&bench.state, bench.io(), bench.arena(), valid); + try bench.exec("INSERT INTO group_sources (group_id, source_id) VALUES (1, 1);"); + + try testing.expectEqual(@as(?Failure, null), applyDelete(&bench.state, bench.io(), created.id)); + try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM group_sources")); + try testing.expectEqual(@as(usize, 2), bench.reloads); +} + +test "a refresh with no manager is unavailable rather than a silent success" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + var statuses: [4]manager_mod.SourceStatus = undefined; + const outcome = applyRefresh(&bench.state, bench.io(), &statuses); + try testing.expect(outcome.fail == .unavailable); + try testing.expectEqual(@as(usize, 0), bench.reloads); +} + +test "a status becomes the flat shape the API answers with" { + var status: manager_mod.SourceStatus = .{ .id = 7, .state = .fetch_failed, .loaded = true }; + const url = "https://a.test/list.txt"; + @memcpy(status.url[0..url.len], url); + status.url_len = url.len; + const message = "connection refused"; + @memcpy(status.last_error[0..message.len], message); + status.last_error_len = message.len; + status.counts = .{ .domains = 10, .wildcards = 2, .skipped_regex = 1 }; + + const view: StatusView = .from(&status); + try testing.expectEqual(@as(i64, 7), view.id); + try testing.expectEqualStrings("fetch_failed", view.state); + try testing.expect(view.loaded); + try testing.expectEqualStrings(url, view.url); + try testing.expectEqualStrings(message, view.last_error); + try testing.expectEqual(@as(u32, 10), view.domains); +} diff --git a/src/web/handlers/clients.zig b/src/web/handlers/clients.zig new file mode 100644 index 0000000..0582060 --- /dev/null +++ b/src/web/handlers/clients.zig @@ -0,0 +1,457 @@ +//! `/api/clients` and `/api/client-prefixes` — which device belongs to which +//! group. +//! +//! Clients have no POST (ruling 9): a row appears because the DNS path saw the +//! address or because an import wrote it. What the API adds is an edit — a name +//! and a group — and an edit is what turns a materialised row into +//! configuration, so every PUT sets `hand_edited` and the stale-client prune +//! stops considering the row (W2's `ClientEdit`). +//! +//! `ip` is not editable. It is the identity `upsertSeen` matches a live device +//! by; rewriting it would collide with the row the tracker re-materialises for +//! the device that still holds the address. A DELETE is how an operator forgets +//! a device, and a device that keeps querying comes back materialised. +//! +//! Client prefixes are one small list resource, replaced whole and atomically +//! (ruling 9): the table is a handful of rows and a partial update of an +//! ordered, priority-carrying set is more ways to be wrong than to be right. +//! Each prefix is stored in canonical text (dotted decimal, RFC 5952, host +//! bits zeroed), so two spellings of one network collide in the API instead +//! of surviving as an ambiguous pair the next restart's validation rejects. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const address = @import("../../platform/address.zig"); +const clients_repo = @import("../../storage/repositories/clients_repo.zig"); +const http_util = @import("../http_util.zig"); +const model = @import("../../config/model.zig"); +const mutations = @import("mutations.zig"); +const server = @import("../server.zig"); + +const Failure = mutations.Failure; +const Request = http_util.Request; +const HandlerError = http_util.HandlerError; + +const group_conflict = "that group does not exist"; +const prefix_conflict = "that prefix is listed twice, or names a group that does not exist"; + +const ClientBody = struct { + name: []const u8 = "", + group_id: i64, +}; + +const PrefixItem = struct { + prefix: []const u8, + group_id: i64, + priority: i32 = 100, +}; + +const PrefixesBody = struct { + client_prefixes: []const PrefixItem, +}; + +// --------------------------------------------------------------------------- +// decisions +// --------------------------------------------------------------------------- + +pub fn applyUpdate( + state: *server.WebState, + io: std.Io, + id: i64, + edit: clients_repo.ClientEdit, +) ?Failure { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return failure, + }; + + state.config_lock.lockUncancelable(io); + const written = clients_repo.updateClient(database, id, edit); + state.config_lock.unlock(io); + + written catch |err| return mutations.dbFailure(err, group_conflict); + return mutations.reload(state, io); +} + +pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return failure, + }; + + state.config_lock.lockUncancelable(io); + const written = clients_repo.deleteClient(database, id); + state.config_lock.unlock(io); + + written catch |err| return mutations.dbFailure(err, group_conflict); + return mutations.reload(state, io); +} + +pub fn applyReplacePrefixes( + state: *server.WebState, + io: std.Io, + arena: Allocator, + items: []const clients_repo.ClientPrefixInput, +) error{OutOfMemory}!?Failure { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return failure, + }; + + // Canonical duplicates are the same UNIQUE collision the database would + // report for identical text, so they answer 409 (ruling 9) before the + // validator can call the second spelling a 400. Unparseable text stays + // out of the set; the validator names it below. + const stored = try arena.alloc(clients_repo.ClientPrefixInput, items.len); + var seen: std.StringHashMapUnmanaged(void) = .empty; + for (stored, items) |*out, item| { + out.* = item; + const parsed = address.Prefix.parse(item.prefix) catch continue; + out.prefix = try canonicalText(arena, parsed); + const entry = try seen.getOrPut(arena, out.prefix); + if (entry.found_existing) return .{ .conflict = prefix_conflict }; + } + + if (try checkPrefixSet(arena, stored)) |problem| return .{ .invalid = problem }; + + state.config_lock.lockUncancelable(io); + const written = clients_repo.replaceClientPrefixes(database, stored); + state.config_lock.unlock(io); + + written catch |err| return mutations.dbFailure(err, prefix_conflict); + return mutations.reload(state, io); +} + +fn canonicalText(arena: Allocator, prefix: address.Prefix) error{OutOfMemory}![]u8 { + // The longest form this writes is an IPv6 prefix, 45 + 4 bytes. + var buf: [64]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + prefix.format(&w) catch unreachable; + return arena.dupe(u8, w.buffered()); +} + +/// The whole candidate list through the real validator, inside the same +/// skeleton `mutations.checkClientPrefix` uses — group ids cannot be mapped +/// to names here, so every row wears the skeleton group and the foreign key +/// still answers for ids that name no group. +fn checkPrefixSet( + arena: Allocator, + items: []const clients_repo.ClientPrefixInput, +) error{OutOfMemory}!?[]const u8 { + const rows = try arena.alloc(model.ClientPrefix, items.len); + for (rows, items) |*row, item| row.* = .{ .prefix = item.prefix, .priority = item.priority }; + return mutations.firstProblem(arena, .{ + .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, + .groups = &.{.{ .name = "default" }}, + .client_prefixes = rows, + }); +} + +// --------------------------------------------------------------------------- +// routes +// --------------------------------------------------------------------------- + +pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + _ = io; + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return mutations.respondFailure(request, failure, "listing clients"), + }; + + const rows = clients_repo.listClientRows(database, request.arena) catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "listing clients"); + + return http_util.respondJson(request, .ok, .{ .clients = rows.items }, &.{}); +} + +pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + _ = io; + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return mutations.respondFailure(request, failure, "reading a client"), + }; + + const row = clients_repo.getClient(database, request.arena, request.id.?) catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "reading a client"); + const found = row orelse return mutations.respondFailure(request, .not_found, ""); + + return http_util.respondJson(request, .ok, found, &.{}); +} + +pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const parsed = http_util.parseBody(ClientBody, request) catch |err| + return mutations.respondBadBody(request, err); + const id = request.id.?; + + if (applyUpdate(state, io, id, .{ + .name = parsed.value.name, + .group_id = parsed.value.group_id, + })) |failure| { + return mutations.respondFailure(request, failure, "updating a client"); + } + + const database = state.config_db.?; + const row = clients_repo.getClient(database, request.arena, id) catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "reading a client"); + const found = row orelse return mutations.respondFailure(request, .not_found, ""); + + return http_util.respondJson(request, .ok, found, &.{}); +} + +pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + if (applyDelete(state, io, request.id.?)) |failure| { + return mutations.respondFailure(request, failure, "deleting a client"); + } + return http_util.respondEmpty(request, .no_content); +} + +pub fn listPrefixes(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + _ = io; + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return mutations.respondFailure(request, failure, "listing client prefixes"), + }; + + const rows = clients_repo.listClientPrefixRows(database, request.arena) catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "listing client prefixes"); + + return http_util.respondJson(request, .ok, .{ .client_prefixes = rows.items }, &.{}); +} + +pub fn putPrefixes(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const parsed = http_util.parseBody(PrefixesBody, request) catch |err| + return mutations.respondBadBody(request, err); + + const items = try request.arena.alloc(clients_repo.ClientPrefixInput, parsed.value.client_prefixes.len); + for (items, parsed.value.client_prefixes) |*item, body| item.* = .{ + .prefix = body.prefix, + .group_id = body.group_id, + .priority = body.priority, + }; + + if (try applyReplacePrefixes(state, io, request.arena, items)) |failure| { + return mutations.respondFailure(request, failure, "replacing the client prefixes"); + } + + const database = state.config_db.?; + const rows = clients_repo.listClientPrefixRows(database, request.arena) catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "listing client prefixes"); + + return http_util.respondJson(request, .ok, .{ .client_prefixes = rows.items }, &.{}); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +fn seedClient(bench: *mutations.Bench) !void { + try bench.exec( + \\INSERT INTO groups (id, name) VALUES (2, 'kids'); + \\INSERT INTO clients (id, ip, group_id, hand_edited, first_seen, last_seen) + \\VALUES (1, '192.168.1.10', 1, 0, 100, 200); + ); +} + +test "editing a client names it, moves it and marks it hand edited" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + try seedClient(&bench); + + const failure = applyUpdate(&bench.state, bench.io(), 1, .{ .name = "laptop", .group_id = 2 }); + try testing.expectEqual(@as(?Failure, null), failure); + try testing.expectEqual(@as(usize, 1), bench.reloads); + + const row = (try clients_repo.getClient(&bench.database, bench.arena(), 1)).?; + try testing.expectEqualStrings("laptop", row.name); + try testing.expectEqualStrings("kids", row.group); + try testing.expect(row.hand_edited); + // The tracker's timestamps and the address are not the API's to move. + try testing.expectEqualStrings("192.168.1.10", row.ip); + try testing.expectEqual(@as(i64, 100), row.first_seen); +} + +test "editing a client into a group that does not exist is a conflict" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + try seedClient(&bench); + + const failure = applyUpdate(&bench.state, bench.io(), 1, .{ .name = "laptop", .group_id = 404 }); + try testing.expectEqualStrings(group_conflict, failure.?.conflict); + try testing.expectEqual(@as(usize, 0), bench.reloads); +} + +test "an id no client holds is a 404 on both update and delete" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + try testing.expectEqual( + Failure.not_found, + applyUpdate(&bench.state, bench.io(), 999, .{ .group_id = 1 }).?, + ); + try testing.expectEqual(Failure.not_found, applyDelete(&bench.state, bench.io(), 999).?); +} + +test "deleting a client removes the row and announces the change" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + try seedClient(&bench); + + try testing.expectEqual(@as(?Failure, null), applyDelete(&bench.state, bench.io(), 1)); + try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM clients")); + try testing.expectEqual(@as(usize, 1), bench.reloads); +} + +test "the prefix list is replaced whole" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + try bench.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');"); + + try testing.expectEqual(@as(?Failure, null), try applyReplacePrefixes( + &bench.state, + bench.io(), + bench.arena(), + &.{ + .{ .prefix = "192.168.1.0/24", .group_id = 1, .priority = 10 }, + .{ .prefix = "192.168.2.0/24", .group_id = 2, .priority = 20 }, + }, + )); + try testing.expectEqual(@as(i64, 2), try bench.queryInt("SELECT count(*) FROM client_prefixes")); + + try testing.expectEqual(@as(?Failure, null), try applyReplacePrefixes( + &bench.state, + bench.io(), + bench.arena(), + &.{.{ .prefix = "10.0.0.0/8", .group_id = 1 }}, + )); + const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena()); + try testing.expectEqual(@as(usize, 1), rows.items.len); + try testing.expectEqualStrings("10.0.0.0/8", rows.items[0].prefix); + try testing.expectEqual(@as(i32, 100), rows.items[0].priority); + try testing.expectEqual(@as(usize, 2), bench.reloads); +} + +test "an empty prefix list clears the table" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + _ = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{ + .{ .prefix = "192.168.1.0/24", .group_id = 1 }, + }); + try testing.expectEqual(@as(?Failure, null), try applyReplacePrefixes( + &bench.state, + bench.io(), + bench.arena(), + &.{}, + )); + try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM client_prefixes")); +} + +test "a malformed prefix is refused and the stored list survives" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + _ = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{ + .{ .prefix = "192.168.1.0/24", .group_id = 1 }, + }); + + const failure = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{ + .{ .prefix = "192.168.2.0/24", .group_id = 1 }, + .{ .prefix = "not-a-prefix", .group_id = 1 }, + }); + try testing.expect(failure.? == .invalid); + + const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena()); + try testing.expectEqual(@as(usize, 1), rows.items.len); + try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix); + try testing.expectEqual(@as(usize, 1), bench.reloads); +} + +test "one prefix twice is a conflict and the old list survives" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + _ = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{ + .{ .prefix = "10.0.0.0/8", .group_id = 1 }, + }); + + const failure = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{ + .{ .prefix = "192.168.1.0/24", .group_id = 1 }, + .{ .prefix = "192.168.1.0/24", .group_id = 1, .priority = 50 }, + }); + try testing.expectEqualStrings(prefix_conflict, failure.?.conflict); + + const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena()); + try testing.expectEqual(@as(usize, 1), rows.items.len); + try testing.expectEqualStrings("10.0.0.0/8", rows.items[0].prefix); +} + +test "two spellings of one prefix in one PUT are a conflict" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + _ = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{ + .{ .prefix = "10.0.0.0/8", .group_id = 1 }, + }); + + const v6_case = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{ + .{ .prefix = "fd00:abcd::/48", .group_id = 1 }, + .{ .prefix = "FD00:ABCD:0:0:0:0:0:0/48", .group_id = 1, .priority = 50 }, + }); + try testing.expectEqualStrings(prefix_conflict, v6_case.?.conflict); + + const host_bits = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{ + .{ .prefix = "192.168.1.0/24", .group_id = 1 }, + .{ .prefix = "192.168.1.55/24", .group_id = 1, .priority = 50 }, + }); + try testing.expectEqualStrings(prefix_conflict, host_bits.?.conflict); + + const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena()); + try testing.expectEqual(@as(usize, 1), rows.items.len); + try testing.expectEqualStrings("10.0.0.0/8", rows.items[0].prefix); + try testing.expectEqual(@as(usize, 1), bench.reloads); +} + +test "a prefix is stored and listed in canonical form" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + try testing.expectEqual(@as(?Failure, null), try applyReplacePrefixes( + &bench.state, + bench.io(), + bench.arena(), + &.{ + .{ .prefix = "FD00:ABCD:0:0:0:0:0:0/48", .group_id = 1 }, + .{ .prefix = "192.168.1.55/24", .group_id = 1, .priority = 50 }, + }, + )); + + // `listPrefixes` serves these rows, so the GET body carries the same text. + const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena()); + try testing.expectEqual(@as(usize, 2), rows.items.len); + try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix); + try testing.expectEqualStrings("fd00:abcd::/48", rows.items[1].prefix); +} + +test "a prefix naming a group that does not exist is a conflict" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const failure = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{ + .{ .prefix = "192.168.1.0/24", .group_id = 404 }, + }); + try testing.expectEqualStrings(prefix_conflict, failure.?.conflict); +} diff --git a/src/web/handlers/groups.zig b/src/web/handlers/groups.zig new file mode 100644 index 0000000..12d7732 --- /dev/null +++ b/src/web/handlers/groups.zig @@ -0,0 +1,448 @@ +//! `/api/groups` — the client groups, and each group's blocklist assignment +//! (ruling 9). +//! +//! A group change is live (ruling 12): the write lands, the snapshot is rebuilt +//! through `state.reload_fn`, and the next query is filtered by the new rules. +//! +//! The group named `default` is the one every client falls back to and the one +//! `config/validate.zig` insists on, so it can be edited but neither renamed +//! nor deleted. Both refusals are 409: the request is well formed and names a +//! row that exists, and the conflict is with an invariant of the configuration. +//! +//! Each route is two functions: an `apply` that decides and writes, and the +//! handler that parses the body and turns the decision into a response. The +//! split is what lets the decisions be tested against an in-memory database +//! with no socket in the way. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const db = @import("../../storage/db.zig"); +const groups_repo = @import("../../storage/repositories/groups_repo.zig"); +const http_util = @import("../http_util.zig"); +const model = @import("../../config/model.zig"); +const mutations = @import("mutations.zig"); +const server = @import("../server.zig"); + +const Failure = mutations.Failure; +const Request = http_util.Request; +const HandlerError = http_util.HandlerError; + +/// The group every client without one of its own belongs to. +pub const default_group_name = "default"; + +const name_conflict = "a group with that name already exists"; + +const Body = struct { + name: []const u8, + safe_search: bool = false, +}; + +const SourcesBody = struct { + source_ids: []const i64, +}; + +const Created = union(enum) { id: i64, fail: Failure }; + +// --------------------------------------------------------------------------- +// decisions +// --------------------------------------------------------------------------- + +pub fn applyCreate( + state: *server.WebState, + io: std.Io, + arena: Allocator, + item: model.Group, +) error{OutOfMemory}!Created { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return .{ .fail = failure }, + }; + if (try mutations.checkGroupName(arena, item.name)) |problem| { + return .{ .fail = .{ .invalid = problem } }; + } + + state.config_lock.lockUncancelable(io); + const inserted = groups_repo.insertGroupRow(database, item); + state.config_lock.unlock(io); + + const id = inserted catch |err| return .{ .fail = mutations.dbFailure(err, name_conflict) }; + if (mutations.reload(state, io)) |failure| return .{ .fail = failure }; + return .{ .id = id }; +} + +pub fn applyUpdate( + state: *server.WebState, + io: std.Io, + arena: Allocator, + id: i64, + item: model.Group, +) error{OutOfMemory}!?Failure { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return failure, + }; + if (try mutations.checkGroupName(arena, item.name)) |problem| { + return .{ .invalid = problem }; + } + + state.config_lock.lockUncancelable(io); + const outcome = updateLocked(database, arena, id, item); + state.config_lock.unlock(io); + + if (outcome) |failure| return failure; + return mutations.reload(state, io); +} + +/// The read and the write are one critical section: the name that decides +/// whether the edit is legal must be the name the update overwrites. +fn updateLocked(database: *db.Db, arena: Allocator, id: i64, item: model.Group) ?Failure { + const row = groups_repo.getGroup(database, arena, id) catch |err| + return mutations.dbFailure(err, name_conflict); + const current = row orelse return .not_found; + if (std.mem.eql(u8, current.name, default_group_name) and + !std.mem.eql(u8, item.name, default_group_name)) + { + return .{ .conflict = "the default group cannot be renamed" }; + } + + groups_repo.updateGroup(database, id, item) catch |err| + return mutations.dbFailure(err, name_conflict); + return null; +} + +pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return failure, + }; + + state.config_lock.lockUncancelable(io); + const outcome = deleteLocked(database, arena, id); + state.config_lock.unlock(io); + + if (outcome) |failure| return failure; + return mutations.reload(state, io); +} + +fn deleteLocked(database: *db.Db, arena: Allocator, id: i64) ?Failure { + // `clients.group_id` has no `ON DELETE`, so a group a client still belongs + // to cannot go; rules, prefixes and assignments cascade (W2's map). + const clients_conflict = "the group still has clients; move them first"; + + const row = groups_repo.getGroup(database, arena, id) catch |err| + return mutations.dbFailure(err, clients_conflict); + const current = row orelse return .not_found; + if (std.mem.eql(u8, current.name, default_group_name)) { + return .{ .conflict = "the default group cannot be deleted" }; + } + + groups_repo.deleteGroup(database, id) catch |err| + return mutations.dbFailure(err, clients_conflict); + return null; +} + +pub fn applySetSources( + state: *server.WebState, + io: std.Io, + id: i64, + source_ids: []const i64, +) ?Failure { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return failure, + }; + + state.config_lock.lockUncancelable(io); + const outcome = groups_repo.setGroupSources(database, id, source_ids); + state.config_lock.unlock(io); + + outcome catch |err| return mutations.dbFailure(err, "one of those blocklist sources does not exist"); + return mutations.reload(state, io); +} + +// --------------------------------------------------------------------------- +// routes +// --------------------------------------------------------------------------- + +pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + _ = io; + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return mutations.respondFailure(request, failure, "listing groups"), + }; + + const rows = groups_repo.listGroupRows(database, request.arena) catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "listing groups"); + + return http_util.respondJson(request, .ok, .{ .groups = rows.items }, &.{}); +} + +pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + _ = io; + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return mutations.respondFailure(request, failure, "reading a group"), + }; + + const row = groups_repo.getGroup(database, request.arena, request.id.?) catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "reading a group"); + const found = row orelse return mutations.respondFailure(request, .not_found, ""); + + return http_util.respondJson(request, .ok, found, &.{}); +} + +pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const parsed = http_util.parseBody(Body, request) catch |err| + return mutations.respondBadBody(request, err); + const item: model.Group = .{ .name = parsed.value.name, .safe_search = parsed.value.safe_search }; + + return switch (try applyCreate(state, io, request.arena, item)) { + .fail => |failure| mutations.respondFailure(request, failure, "creating a group"), + .id => |id| http_util.respondJson(request, .created, .{ + .id = id, + .name = item.name, + .safe_search = item.safe_search, + }, &.{}), + }; +} + +pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const parsed = http_util.parseBody(Body, request) catch |err| + return mutations.respondBadBody(request, err); + const item: model.Group = .{ .name = parsed.value.name, .safe_search = parsed.value.safe_search }; + const id = request.id.?; + + if (try applyUpdate(state, io, request.arena, id, item)) |failure| { + return mutations.respondFailure(request, failure, "updating a group"); + } + return http_util.respondJson(request, .ok, .{ + .id = id, + .name = item.name, + .safe_search = item.safe_search, + }, &.{}); +} + +pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + if (applyDelete(state, io, request.arena, request.id.?)) |failure| { + return mutations.respondFailure(request, failure, "deleting a group"); + } + return http_util.respondEmpty(request, .no_content); +} + +/// `GET /api/groups/{id}/sources` — the assignment the PUT replaces. +pub fn getSources(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + _ = io; + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return mutations.respondFailure(request, failure, "reading a group"), + }; + const id = request.id.?; + + const row = groups_repo.getGroup(database, request.arena, id) catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "reading a group"); + if (row == null) return mutations.respondFailure(request, .not_found, ""); + + const ids = groups_repo.listGroupSourceIds(database, request.arena, id) catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "reading a group's blocklists"); + + return http_util.respondJson(request, .ok, .{ .source_ids = ids.items }, &.{}); +} + +/// `PUT /api/groups/{id}/sources` — the whole assignment, replaced (ruling 9). +/// Sending the same set twice leaves the same server state, which is what makes +/// the UI's checkbox list safe to save repeatedly. +pub fn putSources(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const parsed = http_util.parseBody(SourcesBody, request) catch |err| + return mutations.respondBadBody(request, err); + + if (applySetSources(state, io, request.id.?, parsed.value.source_ids)) |failure| { + return mutations.respondFailure(request, failure, "assigning blocklists to a group"); + } + return http_util.respondJson(request, .ok, .{ .source_ids = parsed.value.source_ids }, &.{}); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "a created group is stored, returned by id and reloaded" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ + .name = "kids", + .safe_search = true, + }); + const id = created.id; + try testing.expectEqual(@as(usize, 1), bench.reloads); + + const row = (try groups_repo.getGroup(&bench.database, bench.arena(), id)).?; + try testing.expectEqualStrings("kids", row.name); + try testing.expect(row.safe_search); +} + +test "a duplicate group name is a conflict, not a validation failure" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + _ = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .name = "kids" }); + const again = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .name = "kids" }); + + try testing.expectEqualStrings(name_conflict, again.fail.conflict); + // The failed write must not have been announced as a change. + try testing.expectEqual(@as(usize, 1), bench.reloads); +} + +test "an empty group name is refused before any write" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .name = "" }); + try testing.expect(created.fail == .invalid); + try testing.expectEqual(@as(usize, 0), bench.reloads); + try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM groups")); +} + +test "updating a group that does not exist is a 404" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const failure = try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, .{ .name = "kids" }); + try testing.expectEqual(Failure.not_found, failure.?); + try testing.expectEqual(@as(usize, 0), bench.reloads); +} + +test "a group edit renames and reloads" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .name = "kids" }); + const failure = try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, .{ + .name = "children", + .safe_search = true, + }); + + try testing.expectEqual(@as(?Failure, null), failure); + try testing.expectEqual(@as(usize, 2), bench.reloads); + const row = (try groups_repo.getGroup(&bench.database, bench.arena(), created.id)).?; + try testing.expectEqualStrings("children", row.name); +} + +test "the default group may be edited but not renamed or deleted" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const kept = try applyUpdate(&bench.state, bench.io(), bench.arena(), 1, .{ + .name = "default", + .safe_search = true, + }); + try testing.expectEqual(@as(?Failure, null), kept); + + const renamed = try applyUpdate(&bench.state, bench.io(), bench.arena(), 1, .{ .name = "primary" }); + try testing.expectEqualStrings("the default group cannot be renamed", renamed.?.conflict); + + const deleted = applyDelete(&bench.state, bench.io(), bench.arena(), 1); + try testing.expectEqualStrings("the default group cannot be deleted", deleted.?.conflict); + try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM groups WHERE id = 1")); +} + +test "a group with clients cannot be deleted" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .name = "kids" }); + try bench.exec("INSERT INTO clients (ip, group_id, first_seen, last_seen) VALUES ('192.168.1.9', 2, 0, 0);"); + + const failure = applyDelete(&bench.state, bench.io(), bench.arena(), created.id); + try testing.expectEqualStrings("the group still has clients; move them first", failure.?.conflict); + try testing.expectEqual(@as(usize, 1), bench.reloads); +} + +test "deleting a group removes it and reloads" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .name = "kids" }); + try testing.expectEqual(@as(?Failure, null), applyDelete(&bench.state, bench.io(), bench.arena(), created.id)); + try testing.expectEqual(@as(usize, 2), bench.reloads); + try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM groups")); + + try testing.expectEqual( + Failure.not_found, + applyDelete(&bench.state, bench.io(), bench.arena(), created.id).?, + ); +} + +test "a group's blocklist assignment is replaced as a set" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + try bench.exec( + \\INSERT INTO blocklist_sources (id, url, name) VALUES + \\ (1, 'https://a.test/list.txt', 'a'), (2, 'https://b.test/list.txt', 'b'); + ); + + try testing.expectEqual( + @as(?Failure, null), + applySetSources(&bench.state, bench.io(), 1, &.{ 1, 2 }), + ); + try testing.expectEqual(@as(i64, 2), try bench.queryInt("SELECT count(*) FROM group_sources")); + + // Idempotent, and a shorter set removes what it leaves out. + try testing.expectEqual(@as(?Failure, null), applySetSources(&bench.state, bench.io(), 1, &.{2})); + try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM group_sources")); + try testing.expectEqual(@as(i64, 2), try bench.queryInt("SELECT source_id FROM group_sources")); + try testing.expectEqual(@as(usize, 2), bench.reloads); +} + +test "assigning a source that does not exist is a conflict" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const failure = applySetSources(&bench.state, bench.io(), 1, &.{404}); + try testing.expectEqualStrings("one of those blocklist sources does not exist", failure.?.conflict); + try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM group_sources")); +} + +test "assigning sources to a group that does not exist is a 404" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + try testing.expectEqual(Failure.not_found, applySetSources(&bench.state, bench.io(), 999, &.{}).?); +} + +test "a write with no configuration database is unavailable, not a crash" { + var state: server.WebState = .{ .gpa = testing.allocator }; + var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena_state.deinit(); + + const created = try applyCreate(&state, undefined, arena_state.allocator(), .{ .name = "kids" }); + try testing.expect(created.fail == .unavailable); +} + +test "a reload failure after a successful write is reported as not applied" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + bench.reload_fails = true; + const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .name = "kids" }); + + try testing.expectEqual(Failure.not_applied, created.fail); + // The row is there: the write succeeded and only the announcement failed. + try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM groups WHERE name = 'kids'")); +} diff --git a/src/web/handlers/health.zig b/src/web/handlers/health.zig new file mode 100644 index 0000000..4592e85 --- /dev/null +++ b/src/web/handlers/health.zig @@ -0,0 +1,236 @@ +//! `GET /api/health` — the rollup a monitor scrapes (ruling 22). +//! +//! Always 200. "degraded" is a fact about the box, not a failure of the +//! request, and answering 503 would make an uptime check flap on a full disk +//! while nxdns is still resolving perfectly well. +//! +//! Unauthenticated and rate-limit exempt, like `/metrics`. +//! +//! `rollup` is pure so the whole degraded matrix is testable without a running +//! server; `handle` only gathers the inputs. + +const std = @import("std"); + +const disk_monitor = @import("../../storage/disk_monitor.zig"); +const http_util = @import("../http_util.zig"); +const metrics = @import("../metrics.zig"); +const pool_mod = @import("../../upstream/pool.zig"); +const server = @import("../server.zig"); + +pub const Disk = struct { + state: []const u8, + free_bytes: u64, + db_bytes: u64, + log_bytes: u64, + sample_failures: u64, +}; + +pub const Upstreams = struct { + available: u32, + total: u32, +}; + +pub const Body = struct { + status: []const u8, + disk: Disk, + upstreams: Upstreams, + queries_dropped: u64, + writer_failed: bool, + refreshes_gated: u64, + /// Null before the first filter snapshot is published. + snapshot_generation: ?u64, +}; + +/// What the rollup is computed from. Every field has a defined value even when +/// its collaborator is missing, and the defaults are the ones a half-wired +/// server should report: no disk reading, no upstreams, nothing published. +pub const Input = struct { + disk_state: disk_monitor.State = .ok, + disk: disk_monitor.Gauges = .{ .free_bytes = 0, .db_bytes = 0, .log_bytes = 0 }, + disk_sample_failures: u64 = 0, + upstreams_available: u32 = 0, + upstreams_total: u32 = 0, + queries_dropped: u64 = 0, + writer_failed: bool = false, + refreshes_gated: u64 = 0, + snapshot_generation: ?u64 = null, +}; + +pub const status_ok = "ok"; +pub const status_degraded = "degraded"; + +/// Ruling 22's three conditions. Each one is something an operator must act on: +/// a disk that is filling stops the query log, a pool with nothing available +/// stops resolution, and a failed writer means rows are being lost right now. +pub fn degraded(input: Input) bool { + return input.disk_state != .ok or input.upstreams_available == 0 or input.writer_failed; +} + +pub fn rollup(input: Input) Body { + return .{ + .status = if (degraded(input)) status_degraded else status_ok, + .disk = .{ + .state = @tagName(input.disk_state), + .free_bytes = input.disk.free_bytes, + .db_bytes = input.disk.db_bytes, + .log_bytes = input.disk.log_bytes, + .sample_failures = input.disk_sample_failures, + }, + .upstreams = .{ .available = input.upstreams_available, .total = input.upstreams_total }, + .queries_dropped = input.queries_dropped, + .writer_failed = input.writer_failed, + .refreshes_gated = input.refreshes_gated, + .snapshot_generation = input.snapshot_generation, + }; +} + +pub fn handle( + state: *server.WebState, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void { + return http_util.respondJson(request, .ok, rollup(collect(state, io)), &.{}); +} + +pub fn collect(state: *server.WebState, io: std.Io) Input { + var input: Input = .{}; + + if (state.monitor) |monitor| { + input.disk_state = monitor.state(); + input.disk = monitor.gauges(); + input.disk_sample_failures = monitor.sample_failures.load(.monotonic); + } + + if (state.pool) |pool| { + var raw: [metrics.max_upstreams]pool_mod.Snapshot = undefined; + const count = metrics.poolSnapshot(pool, io, &raw); + input.upstreams_total = @intCast(count); + for (raw[0..count]) |entry| { + if (entry.available) input.upstreams_available += 1; + } + } + + if (state.logger) |logger| { + input.queries_dropped = logger.queries_dropped.load(.monotonic); + input.writer_failed = logger.writer_failed.load(.monotonic); + } + + if (state.manager) |manager| { + input.refreshes_gated = manager.refreshesGated(); + if (manager.acquire(io)) |acquired| { + defer acquired.release(io); + input.snapshot_generation = acquired.snapshot.generation; + } + } + + return input; +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const logger_mod = @import("../../storage/logger.zig"); +const testing = std.testing; + +/// A box with nothing wrong with it: one upstream up, disk ok, writer alive. +const healthy: Input = .{ + .disk_state = .ok, + .upstreams_available = 1, + .upstreams_total = 1, + .writer_failed = false, +}; + +test "the degraded matrix covers disk state, availability and the writer" { + const cases = [_]struct { input: Input, degraded: bool }{ + .{ .input = healthy, .degraded = false }, + .{ .input = withDisk(healthy, .warn), .degraded = true }, + .{ .input = withDisk(healthy, .critical), .degraded = true }, + .{ .input = withAvailable(healthy, 0), .degraded = true }, + .{ .input = withWriterFailed(healthy), .degraded = true }, + // Two faults at once still report one status. + .{ .input = withWriterFailed(withDisk(healthy, .critical)), .degraded = true }, + // Some upstreams down is not degraded while one still answers. + .{ .input = .{ .upstreams_available = 1, .upstreams_total = 3 }, .degraded = false }, + }; + + for (cases, 0..) |case, i| { + errdefer std.debug.print("case {d}\n", .{i}); + try testing.expectEqual(case.degraded, degraded(case.input)); + try testing.expectEqualStrings( + if (case.degraded) status_degraded else status_ok, + rollup(case.input).status, + ); + } +} + +fn withDisk(input: Input, state: disk_monitor.State) Input { + var out = input; + out.disk_state = state; + return out; +} + +fn withAvailable(input: Input, available: u32) Input { + var out = input; + out.upstreams_available = available; + return out; +} + +fn withWriterFailed(input: Input) Input { + var out = input; + out.writer_failed = true; + return out; +} + +test "the body reports every input verbatim" { + const body = rollup(.{ + .disk_state = .warn, + .disk = .{ .free_bytes = 100, .db_bytes = 20, .log_bytes = 3 }, + .disk_sample_failures = 2, + .upstreams_available = 2, + .upstreams_total = 4, + .queries_dropped = 9, + .writer_failed = false, + .refreshes_gated = 1, + .snapshot_generation = 12, + }); + + try testing.expectEqualStrings("degraded", body.status); + try testing.expectEqualStrings("warn", body.disk.state); + try testing.expectEqual(@as(u64, 100), body.disk.free_bytes); + try testing.expectEqual(@as(u64, 20), body.disk.db_bytes); + try testing.expectEqual(@as(u64, 3), body.disk.log_bytes); + try testing.expectEqual(@as(u64, 2), body.disk.sample_failures); + try testing.expectEqual(@as(u32, 2), body.upstreams.available); + try testing.expectEqual(@as(u32, 4), body.upstreams.total); + try testing.expectEqual(@as(u64, 9), body.queries_dropped); + try testing.expectEqual(@as(u64, 1), body.refreshes_gated); + try testing.expectEqual(@as(?u64, 12), body.snapshot_generation); +} + +test "an unpublished snapshot serializes as null, not as zero" { + var buffer: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + try std.json.Stringify.value(rollup(.{}), .{}, &writer); + try testing.expect(std.mem.containsAtLeast(u8, writer.buffered(), 1, "\"snapshot_generation\":null")); +} + +test "collect reads the logger's counters and reports a bare state as degraded" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var queue_buf: [2]logger_mod.Entry = undefined; + var query_logger: logger_mod.Logger = .init(.{}, &queue_buf); + query_logger.queries_dropped.store(4, .monotonic); + query_logger.writer_failed.store(true, .monotonic); + + var state: server.WebState = .{ .gpa = testing.allocator, .logger = &query_logger }; + const input = collect(&state, io); + + try testing.expectEqual(@as(u64, 4), input.queries_dropped); + try testing.expect(input.writer_failed); + try testing.expectEqual(@as(u32, 0), input.upstreams_total); + try testing.expectEqual(@as(?u64, null), input.snapshot_generation); + try testing.expectEqualStrings("degraded", rollup(input).status); +} diff --git a/src/web/handlers/live.zig b/src/web/handlers/live.zig new file mode 100644 index 0000000..dcdd458 --- /dev/null +++ b/src/web/handlers/live.zig @@ -0,0 +1,184 @@ +//! `GET /api/queries/live` — the query log as it happens (ruling 20). +//! +//! Server-sent events over chunked transfer. The response buffer is EMPTY on +//! purpose: `BodyWriter.flush` pushes only the protocol writer, never the body +//! writer's own buffer (http.zig:780), so with a zero-length buffer every +//! write lands in the chunked drain and one `flush` puts the frame on the +//! wire. `retry: 3000` goes out first so a dropped stream reconnects on the +//! browser's side without configuration. +//! +//! The subscriber owns one hub slot and drains it between waits. A ring +//! overflow means this client is too slow for the query rate; the stream ends +//! cleanly and the reconnecting client re-syncs through `/api/queries` — +//! dropping the client beats holding queries back (PLAN §11.4). The `: ping` +//! heartbeat every 15 s keeps middleboxes from reaping an idle connection. +//! +//! The route is rate-limit exempt (a long-lived stream must not drain its +//! address's token bucket) but pays the per-address SSE connection cap, which +//! binds loopback too: hub slots are a fixed resource. + +const std = @import("std"); + +const address = @import("../../platform/address.zig"); +const http_util = @import("../http_util.zig"); +const queries_repo = @import("../../storage/repositories/queries_repo.zig"); +const server = @import("../server.zig"); +const sse = @import("../sse.zig"); + +pub const retry_preamble = "retry: 3000\n\n"; +pub const heartbeat = ": ping\n\n"; + +/// Ruling 20's heartbeat cadence. Awake clock: a suspended box owes no pings. +pub const heartbeat_interval: std.Io.Clock.Duration = .{ + .raw = .fromSeconds(15), + .clock = .awake, +}; + +/// One event's `data:` payload — the `/api/queries` row fields (ruling 20), +/// minus `id`: a live entry precedes persistence, so no row id exists yet. +pub const EventView = struct { + 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 fn view(entry: *const sse.Entry) EventView { + return .{ + .ts = entry.timestamp, + .domain = entry.domain(), + .client_ip = entry.clientIp(), + .qtype = entry.qtype, + .blocked = entry.blocked, + .block_reason = entry.blockReason(), + .response_time_us = entry.response_time_us, + .cache_hit = entry.cache_hit, + .upstream = entry.upstream(), + }; +} + +/// One `event: query` frame. JSON never contains a raw newline, so the whole +/// payload is a single `data:` line. +pub fn writeEvent(w: *std.Io.Writer, entry: *const sse.Entry) std.Io.Writer.Error!void { + try w.writeAll("event: query\ndata: "); + var stringify: std.json.Stringify = .{ .writer = w }; + try stringify.write(view(entry)); + try w.writeAll("\n\n"); +} + +pub fn stream( + state: *server.WebState, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void { + const hub = state.hub orelse + return http_util.respondError(request, .service_unavailable, "live stream unavailable"); + + const peer = address.NetAddress.fromIp(request.peer); + if (state.limiter) |limiter| { + if (!limiter.tryAcquireSse(io, std.Io.Clock.awake.now(io), peer)) + return http_util.respondError(request, .too_many_requests, "too many live streams from this address"); + } + defer if (state.limiter) |limiter| limiter.releaseSse(io, peer); + + const id = hub.subscribe(io) orelse + return http_util.respondError(request, .service_unavailable, "live stream is full"); + defer hub.unsubscribe(io, id); + + var response = try request.http.respondStreaming(&.{}, .{ + .respond_options = .{ + .extra_headers = &.{ + .{ .name = "content-type", .value = "text/event-stream" }, + .{ .name = "cache-control", .value = "no-store" }, + }, + }, + }); + const w = &response.writer; + try w.writeAll(retry_preamble); + // The browser acts on the headers, not the first event; send them now. + try response.flush(); + + while (true) { + while (hub.next(io, id)) |entry| try writeEvent(w, &entry); + try response.flush(); + + // Checked after the drain: entries that predate the overflow still + // reach the client before the stream ends. + if (hub.overflowed(io, id)) break; + + const wake = hub.wait(io, id, heartbeat_interval) catch return; + if (wake == .timeout) { + try w.writeAll(heartbeat); + try response.flush(); + } + } + + try response.end(); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "the event payload carries the /api/queries row fields, minus id" { + const row_fields = @typeInfo(queries_repo.QueryRow).@"struct".fields; + const view_fields = @typeInfo(EventView).@"struct".fields; + comptime { + std.debug.assert(view_fields.len == row_fields.len - 1); + std.debug.assert(std.mem.eql(u8, row_fields[0].name, "id")); + for (row_fields[1..], view_fields) |row_field, view_field| { + std.debug.assert(std.mem.eql(u8, row_field.name, view_field.name)); + } + } +} + +test "a frame is one event line and one data line of JSON" { + const entry: sse.Entry = .init(.{ + .timestamp = 1_700_000_000, + .domain = "ads.example", + .client_ip = "192.0.2.10", + .qtype = 1, + .blocked = true, + .block_reason = "blocklist_domain", + .response_time_us = 42, + .cache_hit = false, + .upstream = "https://dns.example/dns-query", + }); + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try writeEvent(&writer, &entry); + const frame = writer.buffered(); + + try testing.expect(std.mem.startsWith(u8, frame, "event: query\ndata: {")); + try testing.expect(std.mem.endsWith(u8, frame, "}\n\n")); + try testing.expectEqual(@as(usize, 3), std.mem.count(u8, frame, "\n")); + try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"ts\":1700000000")); + try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"domain\":\"ads.example\"")); + try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"blocked\":true")); + try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"block_reason\":\"blocklist_domain\"")); +} + +test "an unlogged field stays null and an empty string stays a string" { + const entry: sse.Entry = .init(.{ + .timestamp = 1, + .domain = "safe.example", + .client_ip = "192.0.2.11", + }); + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try writeEvent(&writer, &entry); + const frame = writer.buffered(); + + try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"qtype\":null")); + try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"cache_hit\":null")); + try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"upstream\":\"\"")); +} diff --git a/src/web/handlers/local.zig b/src/web/handlers/local.zig new file mode 100644 index 0000000..94a073a --- /dev/null +++ b/src/web/handlers/local.zig @@ -0,0 +1,551 @@ +//! `/api/local-records` and `/api/forward-zones` — the names nxdns answers +//! itself and the zones it hands to another resolver. +//! +//! Both take effect live (ruling 12), and not through the blocklist snapshot: +//! the two tables are rebuilt from the database and published into +//! `state.local_tables`, so the next query reads the new generation. The reload +//! seam is called as well, so the composition root learns about every +//! configuration change through one path. +//! +//! A record's type travels as the word the schema stores — `A`, `AAAA`, +//! `CNAME` — which is also the word the config file uses. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const http_util = @import("../http_util.zig"); +const local_repo = @import("../../storage/repositories/local_repo.zig"); +const model = @import("../../config/model.zig"); +const mutations = @import("mutations.zig"); +const server = @import("../server.zig"); + +const Failure = mutations.Failure; +const Request = http_util.Request; +const HandlerError = http_util.HandlerError; + +const record_conflict = "that name, type and value are already stored"; +const zone_conflict = "that zone already has a resolver"; + +const RecordBody = struct { + name: []const u8, + rtype: []const u8, + value: []const u8, + ttl: u32 = 300, +}; + +const ZoneBody = struct { + zone: []const u8, + resolver: []const u8, +}; + +const Created = union(enum) { id: i64, fail: Failure }; + +fn toRecord(body: RecordBody) union(enum) { record: model.LocalRecord, fail: Failure } { + const rtype = model.RecordType.fromDb(body.rtype) orelse + return .{ .fail = .{ .invalid = "rtype must be 'A', 'AAAA' or 'CNAME'" } }; + return .{ .record = .{ + .name = body.name, + .rtype = rtype, + .value = body.value, + .ttl = body.ttl, + } }; +} + +/// The wire shape of a local record: the row with its type spelled the way the +/// schema spells it. +const RecordView = struct { + id: i64, + name: []const u8, + rtype: []const u8, + value: []const u8, + ttl: u32, + + fn from(row: local_repo.LocalRecordRow) RecordView { + return .{ + .id = row.id, + .name = row.name, + .rtype = row.rtype.toDb(), + .value = row.value, + .ttl = row.ttl, + }; + } +}; + +// --------------------------------------------------------------------------- +// local records: decisions +// --------------------------------------------------------------------------- + +/// Publishes the rebuilt tables and then announces the change. The swap comes +/// first because it is what makes the answer live; the seam only tells the rest +/// of the server that something moved. +/// +/// Callers hold `state.config_lock` across the database write and this call: +/// the rebuild reads the generation the write produced, and the swap publishes +/// in write order — a concurrent mutation cannot overwrite a newer generation +/// with an older one. +fn publish(state: *server.WebState, io: std.Io, arena: Allocator, database: *@import("../../storage/db.zig").Db) ?Failure { + if (mutations.swapLocalTables(state, io, arena, database)) |failure| return failure; + return mutations.reload(state, io); +} + +pub fn applyCreateRecord( + state: *server.WebState, + io: std.Io, + arena: Allocator, + item: model.LocalRecord, +) error{OutOfMemory}!Created { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return .{ .fail = failure }, + }; + if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } }; + + state.config_lock.lockUncancelable(io); + defer state.config_lock.unlock(io); + + const id = local_repo.insertLocalRecordRow(database, item) catch |err| + return .{ .fail = mutations.dbFailure(err, record_conflict) }; + if (publish(state, io, arena, database)) |failure| return .{ .fail = failure }; + return .{ .id = id }; +} + +pub fn applyUpdateRecord( + state: *server.WebState, + io: std.Io, + arena: Allocator, + id: i64, + item: model.LocalRecord, +) error{OutOfMemory}!?Failure { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return failure, + }; + if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .invalid = problem }; + + state.config_lock.lockUncancelable(io); + defer state.config_lock.unlock(io); + + local_repo.updateLocalRecord(database, id, item) catch |err| + return mutations.dbFailure(err, record_conflict); + return publish(state, io, arena, database); +} + +pub fn applyDeleteRecord(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return failure, + }; + + state.config_lock.lockUncancelable(io); + defer state.config_lock.unlock(io); + + local_repo.deleteLocalRecord(database, id) catch |err| + return mutations.dbFailure(err, record_conflict); + return publish(state, io, arena, database); +} + +// --------------------------------------------------------------------------- +// forward zones: decisions +// --------------------------------------------------------------------------- + +pub fn applyCreateZone( + state: *server.WebState, + io: std.Io, + arena: Allocator, + item: model.ForwardZone, +) error{OutOfMemory}!Created { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return .{ .fail = failure }, + }; + if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } }; + + state.config_lock.lockUncancelable(io); + defer state.config_lock.unlock(io); + + const id = local_repo.insertForwardZoneRow(database, item) catch |err| + return .{ .fail = mutations.dbFailure(err, zone_conflict) }; + if (publish(state, io, arena, database)) |failure| return .{ .fail = failure }; + return .{ .id = id }; +} + +pub fn applyUpdateZone( + state: *server.WebState, + io: std.Io, + arena: Allocator, + id: i64, + item: model.ForwardZone, +) error{OutOfMemory}!?Failure { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return failure, + }; + if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .invalid = problem }; + + state.config_lock.lockUncancelable(io); + defer state.config_lock.unlock(io); + + local_repo.updateForwardZone(database, id, item) catch |err| + return mutations.dbFailure(err, zone_conflict); + return publish(state, io, arena, database); +} + +pub fn applyDeleteZone(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return failure, + }; + + state.config_lock.lockUncancelable(io); + defer state.config_lock.unlock(io); + + local_repo.deleteForwardZone(database, id) catch |err| + return mutations.dbFailure(err, zone_conflict); + return publish(state, io, arena, database); +} + +// --------------------------------------------------------------------------- +// local records: routes +// --------------------------------------------------------------------------- + +pub fn listRecords(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + _ = io; + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return mutations.respondFailure(request, failure, "listing local records"), + }; + + const rows = local_repo.listLocalRecordRows(database, request.arena) catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "listing local records"); + + const views = try request.arena.alloc(RecordView, rows.items.len); + for (views, rows.items) |*view, row| view.* = .from(row); + + return http_util.respondJson(request, .ok, .{ .local_records = views }, &.{}); +} + +pub fn getRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + _ = io; + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return mutations.respondFailure(request, failure, "reading a local record"), + }; + + const row = local_repo.getLocalRecord(database, request.arena, request.id.?) catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "reading a local record"); + const found = row orelse return mutations.respondFailure(request, .not_found, ""); + + return http_util.respondJson(request, .ok, RecordView.from(found), &.{}); +} + +pub fn createRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const parsed = http_util.parseBody(RecordBody, request) catch |err| + return mutations.respondBadBody(request, err); + const item = switch (toRecord(parsed.value)) { + .fail => |failure| return mutations.respondFailure(request, failure, "creating a local record"), + .record => |value| value, + }; + + return switch (try applyCreateRecord(state, io, request.arena, item)) { + .fail => |failure| mutations.respondFailure(request, failure, "creating a local record"), + .id => |id| http_util.respondJson(request, .created, .{ + .id = id, + .name = item.name, + .rtype = item.rtype.toDb(), + .value = item.value, + .ttl = item.ttl, + }, &.{}), + }; +} + +pub fn updateRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const parsed = http_util.parseBody(RecordBody, request) catch |err| + return mutations.respondBadBody(request, err); + const item = switch (toRecord(parsed.value)) { + .fail => |failure| return mutations.respondFailure(request, failure, "updating a local record"), + .record => |value| value, + }; + const id = request.id.?; + + if (try applyUpdateRecord(state, io, request.arena, id, item)) |failure| { + return mutations.respondFailure(request, failure, "updating a local record"); + } + return http_util.respondJson(request, .ok, .{ + .id = id, + .name = item.name, + .rtype = item.rtype.toDb(), + .value = item.value, + .ttl = item.ttl, + }, &.{}); +} + +pub fn removeRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + if (applyDeleteRecord(state, io, request.arena, request.id.?)) |failure| { + return mutations.respondFailure(request, failure, "deleting a local record"); + } + return http_util.respondEmpty(request, .no_content); +} + +// --------------------------------------------------------------------------- +// forward zones: routes +// --------------------------------------------------------------------------- + +pub fn listZones(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + _ = io; + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return mutations.respondFailure(request, failure, "listing forward zones"), + }; + + const rows = local_repo.listForwardZoneRows(database, request.arena) catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "listing forward zones"); + + return http_util.respondJson(request, .ok, .{ .forward_zones = rows.items }, &.{}); +} + +pub fn getZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + _ = io; + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return mutations.respondFailure(request, failure, "reading a forward zone"), + }; + + const row = local_repo.getForwardZone(database, request.arena, request.id.?) catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "reading a forward zone"); + const found = row orelse return mutations.respondFailure(request, .not_found, ""); + + return http_util.respondJson(request, .ok, found, &.{}); +} + +pub fn createZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const parsed = http_util.parseBody(ZoneBody, request) catch |err| + return mutations.respondBadBody(request, err); + const item: model.ForwardZone = .{ .zone = parsed.value.zone, .resolver = parsed.value.resolver }; + + return switch (try applyCreateZone(state, io, request.arena, item)) { + .fail => |failure| mutations.respondFailure(request, failure, "creating a forward zone"), + .id => |id| http_util.respondJson(request, .created, .{ + .id = id, + .zone = item.zone, + .resolver = item.resolver, + }, &.{}), + }; +} + +pub fn updateZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const parsed = http_util.parseBody(ZoneBody, request) catch |err| + return mutations.respondBadBody(request, err); + const item: model.ForwardZone = .{ .zone = parsed.value.zone, .resolver = parsed.value.resolver }; + const id = request.id.?; + + if (try applyUpdateZone(state, io, request.arena, id, item)) |failure| { + return mutations.respondFailure(request, failure, "updating a forward zone"); + } + return http_util.respondJson(request, .ok, .{ + .id = id, + .zone = item.zone, + .resolver = item.resolver, + }, &.{}); +} + +pub fn removeZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + if (applyDeleteZone(state, io, request.arena, request.id.?)) |failure| { + return mutations.respondFailure(request, failure, "deleting a forward zone"); + } + return http_util.respondEmpty(request, .no_content); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +const nas: model.LocalRecord = .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 60 }; +const lan: model.ForwardZone = .{ .zone = "lan", .resolver = "udp://10.0.0.1:53" }; + +test "a created local record is answered by the published table" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas); + try testing.expect(created == .id); + try testing.expectEqual(@as(usize, 1), bench.reloads); + + const handle = bench.tables.acquire(bench.io()); + defer handle.release(bench.io()); + try testing.expect(handle.records.hasName("nas.lan")); + try testing.expectEqual(@as(usize, 1), handle.records.lookup("nas.lan", .a).len); +} + +test "an edited local record replaces what the table answers" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas); + const failure = try applyUpdateRecord(&bench.state, bench.io(), bench.arena(), created.id, .{ + .name = "printer.lan", + .rtype = .a, + .value = "192.168.1.11", + .ttl = 120, + }); + try testing.expectEqual(@as(?Failure, null), failure); + + const handle = bench.tables.acquire(bench.io()); + defer handle.release(bench.io()); + try testing.expect(!handle.records.hasName("nas.lan")); + try testing.expect(handle.records.hasName("printer.lan")); +} + +test "a deleted local record leaves the published table empty" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas); + try testing.expectEqual( + @as(?Failure, null), + applyDeleteRecord(&bench.state, bench.io(), bench.arena(), created.id), + ); + + const handle = bench.tables.acquire(bench.io()); + defer handle.release(bench.io()); + try testing.expect(!handle.records.hasName("nas.lan")); + try testing.expectEqual(@as(usize, 2), bench.reloads); +} + +test "a record value the validator refuses never reaches the database" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const bad_value = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), .{ + .name = "nas.lan", + .rtype = .a, + .value = "2001:db8::1", + .ttl = 60, + }); + try testing.expect(bad_value.fail == .invalid); + + const bad_ttl = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), .{ + .name = "nas.lan", + .rtype = .a, + .value = "192.168.1.10", + .ttl = 0, + }); + try testing.expect(bad_ttl.fail == .invalid); + + try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM local_records")); + try testing.expectEqual(@as(usize, 0), bench.reloads); +} + +test "the same record twice is a conflict" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + _ = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas); + const again = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas); + try testing.expectEqualStrings(record_conflict, again.fail.conflict); +} + +test "an id no record holds is a 404 on both update and delete" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + try testing.expectEqual( + Failure.not_found, + (try applyUpdateRecord(&bench.state, bench.io(), bench.arena(), 999, nas)).?, + ); + try testing.expectEqual( + Failure.not_found, + applyDeleteRecord(&bench.state, bench.io(), bench.arena(), 999).?, + ); +} + +test "a created forward zone is matched by the published table" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreateZone(&bench.state, bench.io(), bench.arena(), lan); + try testing.expect(created == .id); + + const handle = bench.tables.acquire(bench.io()); + defer handle.release(bench.io()); + try testing.expect(handle.zones.match("nas.lan") != null); + try testing.expect(handle.zones.match("example.test") == null); +} + +test "a resolver the validator refuses never reaches the database" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreateZone(&bench.state, bench.io(), bench.arena(), .{ + .zone = "lan", + .resolver = "https://10.0.0.1", + }); + try testing.expect(created.fail == .invalid); + try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM forward_zones")); +} + +test "one zone cannot have two resolvers" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + _ = try applyCreateZone(&bench.state, bench.io(), bench.arena(), lan); + const again = try applyCreateZone(&bench.state, bench.io(), bench.arena(), .{ + .zone = "lan", + .resolver = "tcp://10.0.0.2:53", + }); + try testing.expectEqualStrings(zone_conflict, again.fail.conflict); +} + +test "a deleted zone stops matching" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreateZone(&bench.state, bench.io(), bench.arena(), lan); + try testing.expectEqual( + @as(?Failure, null), + applyDeleteZone(&bench.state, bench.io(), bench.arena(), created.id), + ); + + const handle = bench.tables.acquire(bench.io()); + defer handle.release(bench.io()); + try testing.expect(handle.zones.match("nas.lan") == null); +} + +test "a record change is visible to a reader that acquires afterwards" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const before = bench.tables.acquire(bench.io()); + try testing.expect(!before.records.hasName("nas.lan")); + before.release(bench.io()); + + _ = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas); + + const after = bench.tables.acquire(bench.io()); + defer after.release(bench.io()); + try testing.expect(after.records.hasName("nas.lan")); +} + +test "an unknown record type is a 400 before anything is written" { + try testing.expect(toRecord(.{ + .name = "nas.lan", + .rtype = "MX", + .value = "mail.lan", + }).fail == .invalid); + + const good = toRecord(.{ .name = "nas.lan", .rtype = "CNAME", .value = "other.lan" }); + try testing.expectEqual(model.RecordType.cname, good.record.rtype); + try testing.expectEqual(@as(u32, 300), good.record.ttl); +} diff --git a/src/web/handlers/lookup.zig b/src/web/handlers/lookup.zig new file mode 100644 index 0000000..b922d05 --- /dev/null +++ b/src/web/handlers/lookup.zig @@ -0,0 +1,345 @@ +//! `GET /api/lookup?domain=&group_id=` — what the pipeline would do with a name +//! (ruling 14). +//! +//! The answer is assembled from the same three sources a query reads, in the +//! same order PLAN §6 gives them: the local records, the forward zones, then +//! the filter snapshot. Nothing is re-implemented here; a divergence between +//! this endpoint and a real query would make the tool that explains blocking +//! the one thing an operator cannot trust. +//! +//! `evaluate` is pure, so the whole decision table is testable against a +//! hand-built snapshot. The handler adds the two things that need the outside +//! world: the snapshot and local-table handles, and the source row that turns a +//! source index into the URL an operator recognises. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const forward_zones = @import("../../local/forward_zones.zig"); +const http_util = @import("../http_util.zig"); +const matcher = @import("../../filter/matcher.zig"); +const name_mod = @import("../../dns/name.zig"); +const records_mod = @import("../../local/records.zig"); +const safesearch = @import("../../filter/safesearch.zig"); +const server = @import("../server.zig"); +const sources_repo = @import("../../storage/repositories/sources_repo.zig"); +const types = @import("../../dns/types.zig"); + +const log = std.log.scoped(.web_lookup); + +pub const Body = struct { + domain: []const u8, + /// The `groups` row id the decision was made for, not the snapshot index. + group_id: i64, + local_records: bool, + /// The matching zone, or null when no zone claims the name. + forward_zone: ?[]const u8, + blocked: bool, + reason: []const u8, + /// The rule or list entry that decided it; "" when nothing matched. + matched: []const u8, + source_url: ?[]const u8, + safe_search_rewrite: ?[]const u8, +}; + +/// The pure part: everything but the source URL, which is a database read. +pub const Result = struct { + group_id: i64, + local_records: bool, + forward_zone: ?[]const u8, + blocked: bool, + reason: matcher.Reason, + matched: []const u8, + /// `blocklist_sources` row id of the list that matched. + source_id: ?i64, + safe_search_rewrite: ?[]const u8, +}; + +/// `domain` must already be normalized. `group` is an index into +/// `snapshot.groups`. +pub fn evaluate( + snapshot: *const matcher.Snapshot, + group: u32, + domain: []const u8, + records: *const records_mod.Records, + zones: *const forward_zones.Zones, +) Result { + const decision = snapshot.evaluate(group, domain); + const source_id: ?i64 = if (decision.source) |index| snapshot.sources[index].id else null; + + return .{ + .group_id = snapshot.groups[group].id, + .local_records = records.hasName(domain), + .forward_zone = if (zones.match(domain)) |zone| zone.zone else null, + .blocked = decision.blocked, + .reason = decision.reason, + .matched = decision.matched, + .source_id = source_id, + .safe_search_rewrite = if (snapshot.safeSearch(group)) safesearch.lookup(domain) else null, + }; +} + +pub fn body(domain: []const u8, result: Result, source_url: ?[]const u8) Body { + return .{ + .domain = domain, + .group_id = result.group_id, + .local_records = result.local_records, + .forward_zone = result.forward_zone, + .blocked = result.blocked, + .reason = @tagName(result.reason), + .matched = result.matched, + .source_url = source_url, + .safe_search_rewrite = result.safe_search_rewrite, + }; +} + +pub fn handle( + state: *server.WebState, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void { + var raw: [types.max_name_len]u8 = undefined; + const found = http_util.queryValue(request.query, "domain", &raw) catch + return http_util.respondError(request, .bad_request, "domain is not a valid name"); + const text = found orelse + return http_util.respondError(request, .bad_request, "domain is required"); + if (text.len == 0) return http_util.respondError(request, .bad_request, "domain is required"); + + // Ruling 29: the same normalization the query path applies, so the answer + // is about the name the pipeline would actually see. + var normalized_buf: [types.max_name_len]u8 = undefined; + const parsed = name_mod.fromText(text) catch + return http_util.respondError(request, .bad_request, "domain is not a valid name"); + const domain = matcher.normalize(parsed, &normalized_buf); + if (domain.len == 0) return http_util.respondError(request, .bad_request, "domain is not a valid name"); + + const requested_group = http_util.queryInt(i64, request.query, "group_id") catch + return http_util.respondError(request, .bad_request, "group_id must be a row id"); + + const manager = state.manager orelse + return http_util.respondError(request, .service_unavailable, "no snapshot loaded"); + const acquired = manager.acquire(io) orelse + return http_util.respondError(request, .service_unavailable, "no snapshot loaded"); + defer acquired.release(io); + const snapshot = acquired.snapshot; + + const group = if (requested_group) |id| + snapshot.groupIndexById(id) orelse + return http_util.respondError(request, .bad_request, "unknown group_id") + else + snapshot.default_group; + + const result = if (state.handler) |handler| local: { + // The local tables are published like the snapshot is, so the reader + // brackets its lookups the same way (ruling 12). + if (handler.local_tables) |tables| { + const held = tables.acquire(io); + defer held.release(io); + break :local evaluate(snapshot, group, domain, held.records, held.zones); + } + break :local evaluate(snapshot, group, domain, &empty_records, &empty_zones); + } else evaluate(snapshot, group, domain, &empty_records, &empty_zones); + + return http_util.respondJson(request, .ok, body(domain, result, sourceUrl(state, request.arena, result)), &.{}); +} + +const empty_records: records_mod.Records = .empty; +const empty_zones: forward_zones.Zones = .empty; + +/// The blocking list's URL, when there is one to read. A source row that cannot +/// be read leaves the field null rather than failing the lookup: the decision +/// is the answer, and the URL is a label on it. +fn sourceUrl(state: *server.WebState, arena: Allocator, result: Result) ?[]const u8 { + const id = result.source_id orelse return null; + const database = state.config_db orelse return null; + const row = sources_repo.getSource(database, arena, id) catch |err| { + log.warn("lookup could not read source {d}: {s}", .{ id, @errorName(err) }); + return null; + }; + return if (row) |found| found.url else null; +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const model = @import("../../config/model.zig"); +const testing = std.testing; + +const group_ids = [_]i64{ 10, 20 }; + +/// `Snapshot.Input` has no defaults on purpose — the compiler is what stops the +/// manager from forgetting a table. A test that cares about one table would +/// still have to spell out the other eight, so they are spelled out once here. +const Fixture = struct { + groups: []const model.Group, + group_ids: []const i64, + group_sources: []const model.GroupSource = &.{}, + sources: []const model.BlocklistSource = &.{}, + source_ids: []const i64 = &.{}, + rules: []const model.Rule = &.{}, + compiled: []const ?matcher.Snapshot.Compiled = &.{}, +}; + +fn buildSnapshot(fixture: Fixture) !matcher.Snapshot { + return matcher.Snapshot.build(testing.allocator, .{ + .groups = fixture.groups, + .group_ids = fixture.group_ids, + .group_sources = fixture.group_sources, + .sources = fixture.sources, + .source_ids = fixture.source_ids, + .rules = fixture.rules, + .clients = &.{}, + .prefixes = &.{}, + .compiled = fixture.compiled, + .seed = 1, + .generation = 1, + }); +} + +test "a name nothing matches is allowed, with no reason and no source" { + const groups = [_]model.Group{.{ .name = "default" }}; + var snapshot = try buildSnapshot(.{ .groups = &groups, .group_ids = group_ids[0..1] }); + defer snapshot.deinit(); + + const result = evaluate(&snapshot, snapshot.default_group, "example.com", &empty_records, &empty_zones); + try testing.expectEqual(@as(i64, 10), result.group_id); + try testing.expect(!result.blocked); + try testing.expectEqual(matcher.Reason.none, result.reason); + try testing.expectEqualStrings("", result.matched); + try testing.expectEqual(@as(?i64, null), result.source_id); + try testing.expectEqual(@as(?[]const u8, null), result.forward_zone); + try testing.expect(!result.local_records); + try testing.expectEqual(@as(?[]const u8, null), result.safe_search_rewrite); +} + +test "a blocking rule names itself and the pattern that matched" { + const groups = [_]model.Group{.{ .name = "default" }}; + const rules = [_]model.Rule{ + .{ .group = "default", .pattern = "ads.example", .kind = .exact, .action = .block }, + }; + var snapshot = try buildSnapshot(.{ + .groups = &groups, + .group_ids = group_ids[0..1], + .rules = &rules, + }); + defer snapshot.deinit(); + + const result = evaluate(&snapshot, 0, "ads.example", &empty_records, &empty_zones); + try testing.expect(result.blocked); + try testing.expectEqual(matcher.Reason.rule_block_exact, result.reason); + try testing.expectEqualStrings("ads.example", result.matched); + + const rendered = body("ads.example", result, "https://lists.test/a"); + try testing.expectEqualStrings("rule_block_exact", rendered.reason); + try testing.expectEqualStrings("https://lists.test/a", rendered.source_url.?); +} + +test "a blocklist hit carries the source row id the URL is read from" { + const groups = [_]model.Group{.{ .name = "default" }}; + const sources = [_]model.BlocklistSource{.{ .url = "https://lists.test/a", .name = "list a" }}; + const group_sources = [_]model.GroupSource{ + .{ .group = "default", .source_url = "https://lists.test/a" }, + }; + var snapshot = try buildSnapshot(.{ + .groups = &groups, + .group_ids = group_ids[0..1], + .group_sources = &group_sources, + .sources = &sources, + .source_ids = &.{77}, + .compiled = &.{.{ .list_body = "blocked.example\n", .wild_body = "" }}, + }); + defer snapshot.deinit(); + + const result = evaluate(&snapshot, 0, "blocked.example", &empty_records, &empty_zones); + try testing.expect(result.blocked); + try testing.expectEqual(matcher.Reason.blocklist_domain, result.reason); + try testing.expectEqual(@as(?i64, 77), result.source_id); +} + +test "local records and forward zones are reported beside the decision" { + const groups = [_]model.Group{.{ .name = "default" }}; + var snapshot = try buildSnapshot(.{ .groups = &groups, .group_ids = group_ids[0..1] }); + defer snapshot.deinit(); + + var records = try records_mod.Records.build(testing.allocator, &.{ + .{ .name = "nas.lan.home", .rtype = .a, .value = "192.168.1.10" }, + }); + defer records.deinit(testing.allocator); + + var zones = try forward_zones.Zones.build(testing.allocator, &.{ + .{ .zone = "lan.home", .resolver = "udp://192.168.1.1:53" }, + }); + defer zones.deinit(testing.allocator); + + const local = evaluate(&snapshot, 0, "nas.lan.home", &records, &zones); + try testing.expect(local.local_records); + try testing.expectEqualStrings("lan.home", local.forward_zone.?); + + const zone_only = evaluate(&snapshot, 0, "printer.lan.home", &records, &zones); + try testing.expect(!zone_only.local_records); + try testing.expectEqualStrings("lan.home", zone_only.forward_zone.?); + + const neither = evaluate(&snapshot, 0, "example.com", &records, &zones); + try testing.expect(!neither.local_records); + try testing.expectEqual(@as(?[]const u8, null), neither.forward_zone); +} + +test "safe search is reported only for a group that has it on" { + const groups = [_]model.Group{ + .{ .name = "default" }, + .{ .name = "kids", .safe_search = true }, + }; + var snapshot = try buildSnapshot(.{ .groups = &groups, .group_ids = &group_ids }); + defer snapshot.deinit(); + + const off = evaluate(&snapshot, 0, "www.google.com", &empty_records, &empty_zones); + try testing.expectEqual(@as(?[]const u8, null), off.safe_search_rewrite); + + const on = evaluate(&snapshot, 1, "www.google.com", &empty_records, &empty_zones); + try testing.expectEqualStrings(safesearch.lookup("www.google.com").?, on.safe_search_rewrite.?); + try testing.expectEqual(@as(i64, 20), on.group_id); + + // A name safe search says nothing about stays null even in that group. + const unrelated = evaluate(&snapshot, 1, "example.com", &empty_records, &empty_zones); + try testing.expectEqual(@as(?[]const u8, null), unrelated.safe_search_rewrite); +} + +test "a requested group is resolved by row id, not by index" { + const groups = [_]model.Group{ + .{ .name = "default" }, + .{ .name = "kids", .safe_search = true }, + }; + var snapshot = try buildSnapshot(.{ .groups = &groups, .group_ids = &group_ids }); + defer snapshot.deinit(); + + try testing.expectEqual(@as(?u32, 1), snapshot.groupIndexById(20)); + try testing.expectEqual(@as(?u32, null), snapshot.groupIndexById(999)); +} + +test "the body serializes with the fields ruling 14 names" { + const groups = [_]model.Group{.{ .name = "default" }}; + var snapshot = try buildSnapshot(.{ .groups = &groups, .group_ids = group_ids[0..1] }); + defer snapshot.deinit(); + + const result = evaluate(&snapshot, 0, "example.com", &empty_records, &empty_zones); + + var allocating: std.Io.Writer.Allocating = .init(testing.allocator); + defer allocating.deinit(); + try std.json.Stringify.value(body("example.com", result, null), .{}, &allocating.writer); + const text = allocating.written(); + + for ([_][]const u8{ + "\"domain\":\"example.com\"", + "\"group_id\":10", + "\"local_records\":false", + "\"forward_zone\":null", + "\"blocked\":false", + "\"reason\":\"none\"", + "\"matched\":\"\"", + "\"source_url\":null", + "\"safe_search_rewrite\":null", + }) |fragment| { + try testing.expect(std.mem.containsAtLeast(u8, text, 1, fragment)); + } +} diff --git a/src/web/handlers/mutations.zig b/src/web/handlers/mutations.zig new file mode 100644 index 0000000..5bb2ea6 --- /dev/null +++ b/src/web/handlers/mutations.zig @@ -0,0 +1,538 @@ +//! What every mutation handler shares: the collaborator checks, the database +//! error mapping, the per-row validation, and the two ways a change is applied +//! to the running server. +//! +//! Three conventions hold across `web/handlers/`: +//! +//! - Every repository call in this layer allocates from the per-request arena, +//! so the repositories' `freeX` helpers are deliberately not called: the +//! arena is reset when the response is written. Nothing read here outlives +//! the request. +//! - A collaborator this layer needs and does not have is a 503, never a crash +//! and never a silent success. `web.enabled = false` opens no database at +//! all, and a half-wired `WebState` must fail the same way. +//! - Domain outcomes are status codes (ruling 8): `error.NotFound` is 404, +//! `error.Constraint` is 409 with the constraint named in words, a value the +//! validator rejects is 400, and everything else is a 500 whose cause is +//! logged at `warn` and never sent to the client (PLAN §19). +//! +//! `WebState.config_lock` exists because `std.http.Server` connections are served +//! concurrently while all of them share one config connection. SQLite is built +//! in serialized mode, so the connection is safe — but `changes()` and +//! `lastInsertRowid()` describe *the connection's* last statement, and those +//! are exactly what `crud.execStrict` and every `insertXRow` read. Two +//! concurrent writers would read each other's answer. One lock around the +//! database work of a mutation makes the read-back belong to the writer that +//! caused it. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const db = @import("../../storage/db.zig"); +const forward_zones = @import("../../local/forward_zones.zig"); +const local_repo = @import("../../storage/repositories/local_repo.zig"); +const local_records = @import("../../local/records.zig"); +const local_tables = @import("../../server/local_tables.zig"); +const migrations = @import("../../storage/migrations.zig"); +const http_util = @import("../http_util.zig"); +const model = @import("../../config/model.zig"); +const server = @import("../server.zig"); +const settings_repo = @import("../../storage/repositories/settings_repo.zig"); +const validate = @import("../../config/validate.zig"); + +const clients_repo = @import("../../storage/repositories/clients_repo.zig"); +const groups_repo = @import("../../storage/repositories/groups_repo.zig"); +const rules_repo = @import("../../storage/repositories/rules_repo.zig"); +const sources_repo = @import("../../storage/repositories/sources_repo.zig"); +const upstreams_repo = @import("../../storage/repositories/upstreams_repo.zig"); + +const log = std.log.scoped(.web_api); + +pub const Request = http_util.Request; +pub const HandlerError = http_util.HandlerError; + +/// Why a request did not succeed. Every handler in this directory decides in a +/// function that takes no `std.http.Server.Request`, returns one of these, and +/// leaves the response to `respondFailure` — so the decision is testable +/// against an in-memory database, with no socket anywhere. +pub const Failure = union(enum) { + /// The id names no row: 404. + not_found, + /// A constraint of the schema or of the configuration: 409. The text names + /// which one, because the client can only fix what it is told. + conflict: []const u8, + /// A value the validator refused: 400, with the validator's own text. + invalid: []const u8, + /// A collaborator this request needs is not wired: 503. + unavailable: []const u8, + /// Anything else the database reported: 500, cause logged, not sent. + internal: db.Error, + /// The write landed and the running server could not be told about it. + /// A 500 that says exactly that, because retrying the write would not help + /// and reporting success would leave the operator with a stale server. + not_applied, +}; + +pub fn respondFailure(request: *Request, failure: Failure, what: []const u8) HandlerError!void { + return switch (failure) { + .not_found => http_util.respondError(request, .not_found, "not found"), + .conflict => |message| http_util.respondError(request, .conflict, message), + .invalid => |message| http_util.respondError(request, .bad_request, message), + .unavailable => |message| http_util.respondError(request, .service_unavailable, message), + .internal => |err| { + log.warn("{s} failed: {t}", .{ what, err }); + return http_util.respondError(request, .internal_server_error, "internal error"); + }, + .not_applied => http_util.respondError( + request, + .internal_server_error, + "the change was saved but could not be applied; restart nxdns", + ), + }; +} + +/// Turns a repository error into a `Failure`. `conflict` names the constraint +/// that can fire for this statement (W2 documents one per function). +pub fn dbFailure(err: db.Error, conflict: []const u8) Failure { + return switch (err) { + error.NotFound => .not_found, + error.Constraint => .{ .conflict = conflict }, + else => .{ .internal = err }, + }; +} + +/// The config connection, or the 503 a state without one earns. +pub fn configDb(state: *server.WebState) union(enum) { database: *db.Db, fail: Failure } { + if (state.config_db) |database| return .{ .database = database }; + return .{ .fail = .{ .unavailable = "no configuration database" } }; +} + +pub fn nowSeconds(io: std.Io) i64 { + return std.Io.Clock.real.now(io).toSeconds(); +} + +// --------------------------------------------------------------------------- +// applying a change to the running server (ruling 12) +// --------------------------------------------------------------------------- + +/// Rebuilds the blocklist snapshot so the change is live on the next query. +/// +/// 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. +pub fn reload(state: *server.WebState, io: std.Io) ?Failure { + const reload_fn = state.reload_fn orelse return null; + reload_fn(state, io) catch |err| { + log.warn("applying a configuration change failed: {s}", .{@errorName(err)}); + return .not_applied; + }; + return null; +} + +/// Rebuilds the local records and the forward zones from the database and +/// publishes both (ruling 12). Local answers therefore change live, without the +/// blocklist snapshot being rebuilt. +/// +/// Both tables are built before either is published, so a failure leaves the +/// running server with the generation it already had. +pub fn swapLocalTables( + state: *server.WebState, + io: std.Io, + arena: Allocator, + database: *db.Db, +) ?Failure { + const tables = state.local_tables orelse return null; + const gpa = state.gpa; + + const record_rows = local_repo.listLocalRecords(database, arena) catch |err| + return rebuildFailed("reading the local records", @errorName(err)); + + const zone_rows = local_repo.listForwardZones(database, arena) catch |err| + return rebuildFailed("reading the forward zones", @errorName(err)); + + var built_records = local_records.Records.build(gpa, record_rows.items) catch |err| + return rebuildFailed("building the local records", @errorName(err)); + errdefer built_records.deinit(gpa); + + const built_zones = forward_zones.Zones.build(gpa, zone_rows.items) catch |err| + return rebuildFailed("building the forward zones", @errorName(err)); + + tables.swap(io, gpa, built_records, built_zones); + return null; +} + +fn rebuildFailed(what: []const u8, cause: []const u8) Failure { + log.warn("{s} after a change failed: {s}", .{ what, cause }); + return .not_applied; +} + +// --------------------------------------------------------------------------- +// per-row validation +// --------------------------------------------------------------------------- +// +// `config/validate.zig` validates a whole configuration and is not this +// session's to split, so a candidate row is checked by handing the real +// validator a configuration that holds the skeleton it insists on (one default +// group, one upstream) plus the one row under test. The row's own rules — +// domain syntax, record values, rule patterns, CIDR prefixes, source urls, TTL +// ranges — are then exactly the shipped ones, with no second copy to drift. +// +// Cross-row facts are deliberately NOT checked here: a duplicate is the +// database's UNIQUE constraint and answers 409 (ruling 9), and a group that +// does not exist is a foreign key and answers 409 too. Reporting either as a +// 400 would be a second, weaker opinion about the same fact. + +const skeleton_group = "default"; +const skeleton_upstream: model.UpstreamServer = .{ .url = "https://dns.example/dns-query" }; + +/// Runs the shipped validator over `cfg` and returns the first problem's text, +/// or null when the candidate is valid. The text is arena-allocated. +pub fn firstProblem(arena: Allocator, cfg: model.Config) error{OutOfMemory}!?[]const u8 { + var diags: validate.Diagnostics = .init(arena); + defer diags.deinit(); + + validate.validate(cfg, &diags) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => {}, + }; + if (diags.problems.items.len == 0) return null; + const problem = diags.problems.items[0]; + return try std.fmt.allocPrint(arena, "{s}: {s}", .{ problem.path, problem.message }); +} + +/// The configuration skeleton every candidate is validated inside. +fn skeleton(groups: []const model.Group) model.Config { + return .{ + .upstreams = &.{skeleton_upstream}, + .groups = groups, + }; +} + +const default_groups = [_]model.Group{.{ .name = skeleton_group }}; + +pub fn checkLocalRecord(arena: Allocator, record: model.LocalRecord) error{OutOfMemory}!?[]const u8 { + var cfg = skeleton(&default_groups); + cfg.local_records = &.{record}; + return firstProblem(arena, cfg); +} + +pub fn checkForwardZone(arena: Allocator, zone: model.ForwardZone) error{OutOfMemory}!?[]const u8 { + var cfg = skeleton(&default_groups); + cfg.forward_zones = &.{zone}; + return firstProblem(arena, cfg); +} + +pub fn checkRule(arena: Allocator, pattern: []const u8, kind: model.RuleKind) error{OutOfMemory}!?[]const u8 { + var cfg = skeleton(&default_groups); + cfg.rules = &.{.{ .group = skeleton_group, .pattern = pattern, .kind = kind, .action = .block }}; + return firstProblem(arena, cfg); +} + +pub fn checkSource(arena: Allocator, source: model.BlocklistSource) error{OutOfMemory}!?[]const u8 { + var cfg = skeleton(&default_groups); + cfg.blocklist_sources = &.{source}; + return firstProblem(arena, cfg); +} + +pub fn checkClientIp(arena: Allocator, ip: []const u8) error{OutOfMemory}!?[]const u8 { + var cfg = skeleton(&default_groups); + cfg.clients = &.{.{ .ip = ip, .group = skeleton_group }}; + return firstProblem(arena, cfg); +} + +pub fn checkClientPrefix(arena: Allocator, prefix: []const u8, priority: i32) error{OutOfMemory}!?[]const u8 { + var cfg = skeleton(&default_groups); + cfg.client_prefixes = &.{.{ .prefix = prefix, .group = skeleton_group, .priority = priority }}; + return firstProblem(arena, cfg); +} + +/// A group name is checked inside a configuration that already holds the +/// default group, so a candidate named anything else is still complete. +pub fn checkGroupName(arena: Allocator, name: []const u8) error{OutOfMemory}!?[]const u8 { + if (std.mem.eql(u8, name, skeleton_group)) return firstProblem(arena, skeleton(&default_groups)); + const groups = [_]model.Group{ .{ .name = skeleton_group }, .{ .name = name } }; + return firstProblem(arena, skeleton(&groups)); +} + +/// An upstream candidate is validated next to one known-good enabled upstream, +/// so a disabled candidate does not trip the whole-config rule that at least +/// one upstream must be enabled — whether the stored set satisfies that rule is +/// the handler's own guard, not this row check's. The companion's url moves out +/// of the way of a candidate that holds the skeleton url, because a duplicate +/// is the database's answer, not the validator's. +pub fn checkUpstream(arena: Allocator, upstream: model.UpstreamServer) error{OutOfMemory}!?[]const u8 { + const companion: model.UpstreamServer = if (std.mem.eql(u8, upstream.url, skeleton_upstream.url)) + .{ .url = "https://dns-b.example/dns-query" } + else + skeleton_upstream; + var cfg = skeleton(&default_groups); + cfg.upstreams = &.{ upstream, companion }; + return firstProblem(arena, cfg); +} + +/// The 400 a malformed or unparseable body earns. +pub fn respondBadBody(request: *Request, err: anyerror) HandlerError!void { + return switch (err) { + error.TooLarge => http_util.respondError(request, .payload_too_large, "request body too large"), + error.OutOfMemory => error.OutOfMemory, + error.WriteFailed => error.WriteFailed, + error.HttpExpectationFailed => error.HttpExpectationFailed, + // A vanished peer mid-body is the same event as a vanished peer + // mid-response, and ends the connection the same way (ruling 28). + error.ReadFailed => error.WriteFailed, + else => http_util.respondError(request, .bad_request, "malformed request body"), + }; +} + +// --------------------------------------------------------------------------- +// reading the stored configuration +// --------------------------------------------------------------------------- + +/// Every settings row and every collection, as one `model.Config`. The settings +/// PUT validates against this (ruling 16), so the check sees the same +/// configuration the next start would. +/// +/// Every string belongs to `arena`. +pub fn loadConfig(arena: Allocator, database: *db.Db) db.Error!model.Config { + var cfg: model.Config = .{}; + + const pairs = try settings_repo.listSettings(database, arena); + var unknown: usize = 0; + model.fromSettings(pairs.items, &cfg, &unknown) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + // A stored value this build cannot decode is a corrupt row, not a + // client error: the caller reports 500 and the operator sees the log. + error.BadSettingValue => return error.Mismatch, + }; + + const groups = try groups_repo.listGroups(database, arena); + cfg.groups = groups.items; + const upstreams = try upstreams_repo.listUpstreams(database, arena); + cfg.upstreams = upstreams.items; + const clients = try clients_repo.listClients(database, arena); + cfg.clients = clients.items; + const prefixes = try clients_repo.listClientPrefixes(database, arena); + cfg.client_prefixes = prefixes.items; + const sources = try sources_repo.listBlocklistSources(database, arena); + cfg.blocklist_sources = sources.items; + const group_sources = try groups_repo.listGroupSources(database, arena); + cfg.group_sources = group_sources.items; + const rules = try rules_repo.listRules(database, arena); + cfg.rules = rules.items; + const records = try local_repo.listLocalRecords(database, arena); + cfg.local_records = records.items; + const zones = try local_repo.listForwardZones(database, arena); + cfg.forward_zones = zones.items; + + return cfg; +} + +// --------------------------------------------------------------------------- +// the test bench every handler in this directory shares +// --------------------------------------------------------------------------- + +/// A running web layer with no sockets in it: an in-memory config database at +/// the current schema, the local-table holder, a request arena, and a +/// `reload_fn` that counts instead of rebuilding a snapshot. +/// +/// `state` is a field rather than a pointer so the reload seam can find the +/// bench through `@fieldParentPtr` — a `WebState` carries no user data, and a +/// global counter would make two tests in one binary share it. +/// +/// Referenced only by this directory's tests; nothing in a shipped build calls +/// `init`, so it costs nothing there. +pub const Bench = struct { + threaded: std.Io.Threaded, + database: db.Db, + tables: local_tables.LocalTables, + arena_state: std.heap.ArenaAllocator, + state: server.WebState, + reloads: usize, + reload_fails: bool, + + /// Initialises in place: `state` points at fields of `self`. + pub fn init(self: *Bench, gpa: Allocator) !void { + self.threaded = .init(gpa, .{}); + errdefer self.threaded.deinit(); + + self.database = try db.Db.open(":memory:", .{ .mode = .memory }); + errdefer self.database.close(); + try db.applyPragmas(&self.database, .{}); + _ = try migrations.migrate(&self.database); + + self.tables = .empty; + self.arena_state = .init(gpa); + self.reloads = 0; + self.reload_fails = false; + self.state = .{ + .gpa = gpa, + .config_db = &self.database, + .local_tables = &self.tables, + .reload_fn = countingReload, + }; + } + + pub fn deinit(self: *Bench, gpa: Allocator) void { + self.state.live_hash.deinit(gpa); + self.tables.deinit(gpa); + self.arena_state.deinit(); + self.database.close(); + self.threaded.deinit(); + } + + pub fn io(self: *Bench) std.Io { + return self.threaded.io(); + } + + pub fn arena(self: *Bench) Allocator { + return self.arena_state.allocator(); + } + + /// One statement of setup, for the rows a case needs before it starts. + pub fn exec(self: *Bench, sql: [:0]const u8) !void { + try self.database.exec(sql); + } + + pub fn queryInt(self: *Bench, sql: []const u8) !i64 { + return self.database.queryInt(sql); + } + + fn countingReload(state: *server.WebState, io_unused: std.Io) anyerror!void { + _ = io_unused; + const self: *Bench = @alignCast(@fieldParentPtr("state", state)); + self.reloads += 1; + if (self.reload_fails) return error.ReloadFailed; + } +}; + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +fn arenaFor(state: *std.heap.ArenaAllocator) Allocator { + return state.allocator(); +} + +test "the bench wires a state whose reload seam counts" { + var bench: Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + try testing.expect(bench.state.config_db != null); + try testing.expectEqual(@as(?Failure, null), reload(&bench.state, bench.io())); + try testing.expectEqual(@as(usize, 1), bench.reloads); + + bench.reload_fails = true; + try testing.expectEqual(Failure.not_applied, reload(&bench.state, bench.io()).?); + try testing.expectEqual(@as(usize, 2), bench.reloads); +} + +test "the schema the bench opens already holds the default group" { + var bench: Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT id FROM groups WHERE name = 'default'")); +} + +test "a database error maps to the status its cause deserves" { + try testing.expectEqual(Failure.not_found, dbFailure(error.NotFound, "x")); + try testing.expectEqualStrings("taken", dbFailure(error.Constraint, "taken").conflict); + try testing.expectEqual(db.Error.Busy, dbFailure(error.Busy, "x").internal); +} + +test "a valid candidate row reports no problem" { + var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena_state.deinit(); + const arena = arenaFor(&arena_state); + + try testing.expectEqual( + @as(?[]const u8, null), + try checkLocalRecord(arena, .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 60 }), + ); + try testing.expectEqual( + @as(?[]const u8, null), + try checkForwardZone(arena, .{ .zone = "lan", .resolver = "udp://10.0.0.1:53" }), + ); + try testing.expectEqual(@as(?[]const u8, null), try checkRule(arena, "*.ads.example", .wildcard)); + try testing.expectEqual(@as(?[]const u8, null), try checkClientIp(arena, "192.168.1.10")); + try testing.expectEqual(@as(?[]const u8, null), try checkClientPrefix(arena, "192.168.1.0/24", 100)); + try testing.expectEqual(@as(?[]const u8, null), try checkGroupName(arena, "kids")); + try testing.expectEqual(@as(?[]const u8, null), try checkGroupName(arena, "default")); + try testing.expectEqual( + @as(?[]const u8, null), + try checkSource(arena, .{ .url = "https://example.test/list.txt", .name = "list" }), + ); + try testing.expectEqual( + @as(?[]const u8, null), + try checkUpstream(arena, .{ .url = "tls://1.1.1.1:853", .tls_name = "one.one.one.one" }), + ); +} + +test "a disabled upstream candidate is valid on its own merits" { + var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena_state.deinit(); + const arena = arenaFor(&arena_state); + + try testing.expectEqual( + @as(?[]const u8, null), + try checkUpstream(arena, .{ .url = "https://dns.other/dns-query", .enabled = false }), + ); + // The skeleton's own url must not read as a duplicate of the companion. + try testing.expectEqual( + @as(?[]const u8, null), + try checkUpstream(arena, .{ .url = skeleton_upstream.url, .enabled = false }), + ); + // A disabled row's other fields are still judged. + const bad = try checkUpstream(arena, .{ .url = "udp://1.1.1.1:53", .enabled = false }); + try testing.expect(bad != null); +} + +test "an invalid candidate row names the field that failed" { + var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena_state.deinit(); + const arena = arenaFor(&arena_state); + + const bad_value = try checkLocalRecord( + arena, + .{ .name = "nas.lan", .rtype = .a, .value = "not-an-ip", .ttl = 60 }, + ); + try testing.expect(bad_value != null); + try testing.expect(std.mem.startsWith(u8, bad_value.?, "local_records[0].value:")); + + const bad_ttl = try checkLocalRecord( + arena, + .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 0 }, + ); + try testing.expect(bad_ttl != null); + + const bad_resolver = try checkForwardZone(arena, .{ .zone = "lan", .resolver = "http://10.0.0.1" }); + try testing.expect(bad_resolver != null); + + const bad_pattern = try checkRule(arena, "ads.*.example", .exact); + try testing.expect(bad_pattern != null); + + const bad_prefix = try checkClientPrefix(arena, "192.168.1.0", 100); + try testing.expect(bad_prefix != null); + + const empty_group = try checkGroupName(arena, ""); + try testing.expect(empty_group != null); + + const bad_source = try checkSource(arena, .{ .url = "ftp://example.test/list", .name = "list" }); + try testing.expect(bad_source != null); +} + +test "a candidate is judged alone, so a duplicate is left to the database" { + var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena_state.deinit(); + const arena = arenaFor(&arena_state); + + // The same zone twice would be `DuplicateForwardZone` in a whole config; + // one candidate row cannot collide with itself, and the UNIQUE constraint + // is what answers 409. + try testing.expectEqual( + @as(?[]const u8, null), + try checkForwardZone(arena, .{ .zone = "lan", .resolver = "udp://10.0.0.1:53" }), + ); +} diff --git a/src/web/handlers/pause.zig b/src/web/handlers/pause.zig new file mode 100644 index 0000000..c853a59 --- /dev/null +++ b/src/web/handlers/pause.zig @@ -0,0 +1,176 @@ +//! `/api/pause` — the global pause of filtering (ruling 15). +//! +//! Pausing suspends filtering only: local records, forward zones, the cache, +//! the upstream and the query log all keep working (milestone-7 ruling 18). +//! +//! `until` is null both while filtering is on and while an indefinite pause is +//! in force, so `paused` is the field that disambiguates the two. The pause is +//! deliberately not persisted, so a restart resumes filtering. + +const std = @import("std"); + +const http_util = @import("../http_util.zig"); +const mutations = @import("mutations.zig"); +const pause_mod = @import("../../server/pause.zig"); +const server = @import("../server.zig"); + +const Failure = mutations.Failure; +const Request = http_util.Request; +const HandlerError = http_util.HandlerError; + +/// Longest pause a single request may set: one week. An operator who wants +/// longer wants the indefinite pause, which is one word shorter to ask for. +pub const max_duration_seconds: u32 = 7 * 24 * 3600; + +const Body = struct { + paused: bool, + duration_seconds: ?u32 = null, +}; + +pub const View = struct { + paused: bool, + /// Unix seconds when filtering resumes; null while unpaused and null while + /// the pause is indefinite. + until: ?i64, +}; + +/// The state a `Pause` is in at `now_s`, in the shape the API answers with. +pub fn view(pause: *const pause_mod.Pause, now_s: i64) View { + const until = pause.until.load(.monotonic); + if (until == 0) return .{ .paused = false, .until = null }; + if (until < 0) return .{ .paused = true, .until = null }; + if (now_s >= until) return .{ .paused = false, .until = null }; + return .{ .paused = true, .until = until }; +} + +pub fn apply( + state: *server.WebState, + io: std.Io, + body: Body, +) union(enum) { view: View, fail: Failure } { + const pause = state.pause orelse return .{ .fail = .{ .unavailable = "filtering is not running" } }; + if (body.duration_seconds) |seconds| { + if (!body.paused) return .{ .fail = .{ + .invalid = "duration_seconds is only meaningful with paused = true", + } }; + if (seconds == 0 or seconds > max_duration_seconds) return .{ .fail = .{ + .invalid = "duration_seconds must be 1 to 604800", + } }; + } + + const now_s = mutations.nowSeconds(io); + if (body.paused) { + pause.pauseFor(now_s, body.duration_seconds); + } else { + pause.unpause(); + } + return .{ .view = view(pause, now_s) }; +} + +pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const pause = state.pause orelse + return mutations.respondFailure(request, .{ .unavailable = "filtering is not running" }, ""); + + return http_util.respondJson(request, .ok, view(pause, mutations.nowSeconds(io)), &.{}); +} + +pub fn post(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const parsed = http_util.parseBody(Body, request) catch |err| + return mutations.respondBadBody(request, err); + + return switch (apply(state, io, parsed.value)) { + .fail => |failure| mutations.respondFailure(request, failure, "pausing"), + .view => |current| http_util.respondJson(request, .ok, current, &.{}), + }; +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "an unpaused server reports neither a pause nor an expiry" { + const pause: pause_mod.Pause = .{}; + const current = view(&pause, 1_700_000_000); + try testing.expect(!current.paused); + try testing.expectEqual(@as(?i64, null), current.until); +} + +test "an indefinite pause reports paused with no expiry" { + var pause: pause_mod.Pause = .{}; + pause.pauseFor(1_000, null); + const current = view(&pause, 1_000); + try testing.expect(current.paused); + try testing.expectEqual(@as(?i64, null), current.until); +} + +test "a timed pause reports the second filtering comes back" { + var pause: pause_mod.Pause = .{}; + pause.pauseFor(1_000, 60); + try testing.expectEqual(@as(?i64, 1_060), view(&pause, 1_000).until); + // Past its expiry it reads as unpaused, exactly as the query path sees it. + try testing.expect(!view(&pause, 1_060).paused); + try testing.expectEqual(@as(?i64, null), view(&pause, 1_060).until); +} + +test "a pause round trip goes through the running pause flag" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + var pause: pause_mod.Pause = .{}; + bench.state.pause = &pause; + + const paused = apply(&bench.state, bench.io(), .{ .paused = true, .duration_seconds = 60 }); + try testing.expect(paused.view.paused); + try testing.expect(pause.isPaused(mutations.nowSeconds(bench.io()))); + try testing.expect(paused.view.until.? > mutations.nowSeconds(bench.io())); + + const resumed = apply(&bench.state, bench.io(), .{ .paused = false }); + try testing.expect(!resumed.view.paused); + try testing.expect(!pause.isPaused(mutations.nowSeconds(bench.io()))); +} + +test "an indefinite pause set through the API never expires" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + var pause: pause_mod.Pause = .{}; + bench.state.pause = &pause; + + const paused = apply(&bench.state, bench.io(), .{ .paused = true }); + try testing.expect(paused.view.paused); + try testing.expectEqual(@as(?i64, null), paused.view.until); + try testing.expect(pause.isPaused(std.math.maxInt(i64) - 1)); +} + +test "a duration outside the range, or one sent with paused false, is refused" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + var pause: pause_mod.Pause = .{}; + bench.state.pause = &pause; + + try testing.expect(apply(&bench.state, bench.io(), .{ + .paused = true, + .duration_seconds = 0, + }).fail == .invalid); + try testing.expect(apply(&bench.state, bench.io(), .{ + .paused = true, + .duration_seconds = max_duration_seconds + 1, + }).fail == .invalid); + try testing.expect(apply(&bench.state, bench.io(), .{ + .paused = false, + .duration_seconds = 60, + }).fail == .invalid); + + try testing.expect(!pause.isPaused(mutations.nowSeconds(bench.io()))); +} + +test "pausing a server that has no pause flag is unavailable" { + var state: server.WebState = .{ .gpa = testing.allocator }; + try testing.expect(apply(&state, undefined, .{ .paused = true }).fail == .unavailable); +} diff --git a/src/web/handlers/queries.zig b/src/web/handlers/queries.zig new file mode 100644 index 0000000..73a7bf2 --- /dev/null +++ b/src/web/handlers/queries.zig @@ -0,0 +1,325 @@ +//! `GET /api/queries` — the query log, newest first (ruling 11). +//! +//! Keyset pagination rather than an offset: the table is append-only and the +//! UI reads the head of it, so `id < before` is one index seek no matter how +//! deep the client has scrolled, and rows arriving between two pages cannot +//! shift the window and duplicate a row. +//! +//! Filter parsing is separated from fetching, because parsing is where the +//! input validation of PLAN §19 lives and it is worth testing on its own. Every +//! value is length-capped here and bound as a SQL parameter by the repository; +//! nothing this file reads is ever concatenated into a statement. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const db = @import("../../storage/db.zig"); +const http_util = @import("../http_util.zig"); +const queries_repo = @import("../../storage/repositories/queries_repo.zig"); +const server = @import("../server.zig"); + +const log = std.log.scoped(.web_queries); + +pub const default_limit: u32 = 100; +pub const max_limit: u32 = queries_repo.max_limit; + +/// A domain filter longer than the longest legal domain name matches nothing. +pub const max_domain_len = 253; + +/// Long enough for an IPv6 address with a zone identifier. +pub const max_client_len = 64; + +/// 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 +/// stack frame. +pub const Buffers = struct { + domain: [max_domain_len]u8 = undefined, + client: [max_client_len]u8 = undefined, +}; + +pub const Page = struct { + queries: []const queries_repo.QueryRow, + /// The cursor for the next page, or null when this page is the last one. + next_before: ?i64, +}; + +pub const FilterError = error{ + BadLimit, + BadBefore, + BadDomain, + BadClient, + BadBlocked, + BadSince, + BadUntil, +}; + +/// Ruling 11's query string. An absent parameter drops the filter; a malformed +/// one is a 400 rather than a filter silently left off, which would answer a +/// question the client did not ask. +pub fn parseFilter(query: []const u8, buffers: *Buffers) FilterError!queries_repo.QueryFilter { + var filter: queries_repo.QueryFilter = .{}; + + if (http_util.queryInt(u32, query, "limit") catch return error.BadLimit) |limit| { + if (limit == 0 or limit > max_limit) return error.BadLimit; + filter.limit = limit; + } + + if (http_util.queryInt(i64, query, "before") catch return error.BadBefore) |before| { + // Row ids are positive, so a non-positive cursor is a client bug, not + // an empty page. + if (before <= 0) return error.BadBefore; + filter.before = before; + } + + if (http_util.queryValue(query, "domain", &buffers.domain) catch return error.BadDomain) |domain| { + if (domain.len != 0) filter.domain_substring = domain; + } + + if (http_util.queryValue(query, "client", &buffers.client) catch return error.BadClient) |client| { + if (client.len != 0) filter.client = client; + } + + filter.blocked = http_util.queryBool(query, "blocked") catch return error.BadBlocked; + filter.since = http_util.queryInt(i64, query, "since") catch return error.BadSince; + filter.until = http_util.queryInt(i64, query, "until") catch return error.BadUntil; + + return filter; +} + +pub fn message(err: FilterError) []const u8 { + return switch (err) { + error.BadLimit => "limit must be between 1 and 1000", + error.BadBefore => "before must be a positive row id", + error.BadDomain => "domain is not a valid filter", + error.BadClient => "client is not a valid filter", + error.BadBlocked => "blocked must be true or false", + error.BadSince => "since must be a unix timestamp in seconds", + error.BadUntil => "until must be a unix timestamp in seconds", + }; +} + +/// A full page carries a cursor and a short one does not: a client stops when +/// `next_before` is null, without a count query telling it how many rows exist. +pub fn page( + database: *db.Db, + arena: Allocator, + filter: queries_repo.QueryFilter, +) db.Error!Page { + const rows = try queries_repo.selectQueries(database, arena, filter); + const full = rows.items.len == @min(filter.limit, max_limit); + return .{ + .queries = rows.items, + .next_before = if (full and rows.items.len != 0) rows.items[rows.items.len - 1].id else null, + }; +} + +pub fn list( + state: *server.WebState, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void { + _ = io; + + var buffers: Buffers = .{}; + const filter = parseFilter(request.query, &buffers) catch |err| { + return http_util.respondError(request, .bad_request, message(err)); + }; + + const database = state.querylog_db orelse + return http_util.respondError(request, .service_unavailable, "query log unavailable"); + + const result = page(database, request.arena, filter) catch |err| { + // The one thing this handler logs: a database fault is a property of + // the box, not of the request, and the client is told nothing about it. + log.warn("query log read failed: {s}", .{@errorName(err)}); + return http_util.respondError(request, .internal_server_error, "internal error"); + }; + + return http_util.respondJson(request, .ok, result, &.{}); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const querylog_schema = @import("../../storage/querylog_schema.zig"); +const testing = std.testing; + +test "an empty query string is the default page" { + var buffers: Buffers = .{}; + const filter = try parseFilter("", &buffers); + try testing.expectEqual(default_limit, filter.limit); + try testing.expectEqual(@as(?i64, null), filter.before); + try testing.expectEqual(@as(?[]const u8, null), filter.domain_substring); + try testing.expectEqual(@as(?bool, null), filter.blocked); +} + +test "every filter reaches the repository untouched" { + var buffers: Buffers = .{}; + const filter = try parseFilter( + "limit=250&before=900&domain=ads.example&client=192.0.2.10&blocked=true&since=100&until=200", + &buffers, + ); + try testing.expectEqual(@as(u32, 250), filter.limit); + try testing.expectEqual(@as(?i64, 900), filter.before); + try testing.expectEqualStrings("ads.example", filter.domain_substring.?); + try testing.expectEqualStrings("192.0.2.10", filter.client.?); + try testing.expectEqual(@as(?bool, true), filter.blocked); + try testing.expectEqual(@as(?i64, 100), filter.since); + try testing.expectEqual(@as(?i64, 200), filter.until); +} + +test "an empty string filter is no filter at all" { + var buffers: Buffers = .{}; + const filter = try parseFilter("domain=&client=", &buffers); + try testing.expectEqual(@as(?[]const u8, null), filter.domain_substring); + try testing.expectEqual(@as(?[]const u8, null), filter.client); +} + +test "each malformed parameter names itself in a 400" { + var buffers: Buffers = .{}; + try testing.expectError(error.BadLimit, parseFilter("limit=0", &buffers)); + try testing.expectError(error.BadLimit, parseFilter("limit=1001", &buffers)); + try testing.expectError(error.BadLimit, parseFilter("limit=ten", &buffers)); + try testing.expectError(error.BadBefore, parseFilter("before=0", &buffers)); + try testing.expectError(error.BadBefore, parseFilter("before=-4", &buffers)); + try testing.expectError(error.BadBlocked, parseFilter("blocked=maybe", &buffers)); + try testing.expectError(error.BadSince, parseFilter("since=yesterday", &buffers)); + try testing.expectError(error.BadUntil, parseFilter("until=", &buffers)); + try testing.expectError(error.BadDomain, parseFilter("domain=%zz", &buffers)); + + var long: [max_domain_len + 8]u8 = @splat('a'); + var text: std.ArrayList(u8) = .empty; + defer text.deinit(testing.allocator); + try text.appendSlice(testing.allocator, "domain="); + try text.appendSlice(testing.allocator, &long); + try testing.expectError(error.BadDomain, parseFilter(text.items, &buffers)); +} + +test "the limit cap is the repository's" { + var buffers: Buffers = .{}; + try testing.expectEqual(max_limit, (try parseFilter("limit=1000", &buffers)).limit); + try testing.expectEqual(@as(u32, 1000), queries_repo.max_limit); +} + +fn openLog() !db.Db { + var database = try db.Db.open(":memory:", .{ .mode = .memory }); + errdefer database.close(); + try db.applyPragmas(&database, .{}); + try database.exec(querylog_schema.ddl); + return database; +} + +fn seed(database: *db.Db, count: usize) !void { + var writer = try queries_repo.BatchWriter.init(database); + defer writer.deinit(); + var rows: [16]queries_repo.Row = undefined; + for (rows[0..count], 0..) |*row, i| { + row.* = .{ + .timestamp = 1_700_000_000 + @as(i64, @intCast(i)), + .domain = if (i % 2 == 0) "ads.example" else "safe.example", + .client_ip = "192.0.2.10", + .qtype = 1, + .blocked = i % 2 == 0, + .block_reason = if (i % 2 == 0) "blocklist_domain" else null, + .response_time_us = 500, + .cache_hit = false, + .upstream = null, + }; + } + try writer.writeBatch(rows[0..count]); +} + +test "a full page carries a cursor and the last page does not" { + var database = try openLog(); + defer database.close(); + try seed(&database, 5); + + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const first = try page(&database, arena.allocator(), .{ .limit = 2 }); + try testing.expectEqual(@as(usize, 2), first.queries.len); + try testing.expectEqual(first.queries[1].id, first.next_before.?); + // Newest first. + try testing.expect(first.queries[0].id > first.queries[1].id); + + const second = try page(&database, arena.allocator(), .{ .limit = 2, .before = first.next_before }); + try testing.expect(second.queries[0].id < first.queries[1].id); + + const third = try page(&database, arena.allocator(), .{ .limit = 2, .before = second.next_before }); + try testing.expectEqual(@as(usize, 1), third.queries.len); + try testing.expectEqual(@as(?i64, null), third.next_before); +} + +test "an empty result is a page with no cursor" { + var database = try openLog(); + defer database.close(); + + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const empty = try page(&database, arena.allocator(), .{}); + try testing.expectEqual(@as(usize, 0), empty.queries.len); + try testing.expectEqual(@as(?i64, null), empty.next_before); +} + +test "the parsed filters narrow the rows the page returns" { + var database = try openLog(); + defer database.close(); + try seed(&database, 6); + + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + var buffers: Buffers = .{}; + const blocked = try page( + &database, + arena.allocator(), + try parseFilter("blocked=true", &buffers), + ); + try testing.expectEqual(@as(usize, 3), blocked.queries.len); + for (blocked.queries) |row| try testing.expect(row.blocked); + + const by_domain = try page( + &database, + arena.allocator(), + try parseFilter("domain=safe", &buffers), + ); + try testing.expectEqual(@as(usize, 3), by_domain.queries.len); + for (by_domain.queries) |row| try testing.expectEqualStrings("safe.example", row.domain); + + const nobody = try page( + &database, + arena.allocator(), + try parseFilter("client=198.51.100.1", &buffers), + ); + try testing.expectEqual(@as(usize, 0), nobody.queries.len); +} + +test "the page serializes as the envelope ruling 11 defines" { + var database = try openLog(); + defer database.close(); + try seed(&database, 1); + + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const result = try page(&database, arena.allocator(), .{ .limit = 100 }); + var allocating: std.Io.Writer.Allocating = .init(testing.allocator); + defer allocating.deinit(); + try std.json.Stringify.value(result, .{}, &allocating.writer); + const text = allocating.written(); + + try testing.expect(std.mem.startsWith(u8, text, "{\"queries\":[")); + try testing.expect(std.mem.endsWith(u8, text, "\"next_before\":null}")); + for ([_][]const u8{ + "\"id\":", "\"ts\":", "\"domain\":", "\"client_ip\":", + "\"qtype\":", "\"blocked\":", "\"cache_hit\":", "\"upstream\":", + "\"upstream\":", "\"response_time_us\":", "\"block_reason\":", + }) |field| { + try testing.expect(std.mem.containsAtLeast(u8, text, 1, field)); + } + // W1's ruling: a NULL column reads as "", and "" stays "" on the wire. + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"upstream\":\"\"")); +} diff --git a/src/web/handlers/rules.zig b/src/web/handlers/rules.zig new file mode 100644 index 0000000..3980938 --- /dev/null +++ b/src/web/handlers/rules.zig @@ -0,0 +1,361 @@ +//! `/api/rules` — the per-group allow and block rules. +//! +//! A rule names its group by row id, not by name: the API identifies every +//! resource by id, and a `group_id` no group holds is then the foreign-key +//! violation it is (409) rather than a lookup that quietly writes nothing. +//! +//! `kind` and `action` travel as the words the database stores (`exact` / +//! `wildcard`, `allow` / `block`), so one vocabulary describes a rule in the +//! config file, in the database and on the wire. +//! +//! Rules take effect live: the write is followed by the reload seam, and the +//! next query is matched against the new snapshot (ruling 12). + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const http_util = @import("../http_util.zig"); +const model = @import("../../config/model.zig"); +const mutations = @import("mutations.zig"); +const rules_repo = @import("../../storage/repositories/rules_repo.zig"); +const server = @import("../server.zig"); + +const Failure = mutations.Failure; +const Request = http_util.Request; +const HandlerError = http_util.HandlerError; + +const group_conflict = "that group does not exist"; + +const Body = struct { + group_id: i64, + pattern: []const u8, + kind: []const u8, + action: []const u8, +}; + +const Created = union(enum) { id: i64, fail: Failure }; + +/// A body's `kind` and `action` decoded, or the 400 that says which word was +/// not understood. +fn toInput(body: Body) union(enum) { input: rules_repo.RuleInput, fail: Failure } { + const kind = model.RuleKind.fromDb(body.kind) orelse + return .{ .fail = .{ .invalid = "kind must be 'exact' or 'wildcard'" } }; + const action = model.RuleAction.fromDb(body.action) orelse + return .{ .fail = .{ .invalid = "action must be 'allow' or 'block'" } }; + return .{ .input = .{ + .group_id = body.group_id, + .pattern = body.pattern, + .kind = kind, + .action = action, + } }; +} + +// --------------------------------------------------------------------------- +// decisions +// --------------------------------------------------------------------------- + +pub fn applyCreate( + state: *server.WebState, + io: std.Io, + arena: Allocator, + item: rules_repo.RuleInput, +) error{OutOfMemory}!Created { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return .{ .fail = failure }, + }; + if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| { + return .{ .fail = .{ .invalid = problem } }; + } + + state.config_lock.lockUncancelable(io); + const inserted = rules_repo.insertRuleRow(database, item, mutations.nowSeconds(io)); + state.config_lock.unlock(io); + + const id = inserted catch |err| return .{ .fail = mutations.dbFailure(err, group_conflict) }; + if (mutations.reload(state, io)) |failure| return .{ .fail = failure }; + return .{ .id = id }; +} + +pub fn applyUpdate( + state: *server.WebState, + io: std.Io, + arena: Allocator, + id: i64, + item: rules_repo.RuleInput, +) error{OutOfMemory}!?Failure { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return failure, + }; + if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| { + return .{ .invalid = problem }; + } + + state.config_lock.lockUncancelable(io); + const written = rules_repo.updateRule(database, id, item); + state.config_lock.unlock(io); + + written catch |err| return mutations.dbFailure(err, group_conflict); + return mutations.reload(state, io); +} + +pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return failure, + }; + + state.config_lock.lockUncancelable(io); + const written = rules_repo.deleteRule(database, id); + state.config_lock.unlock(io); + + written catch |err| return mutations.dbFailure(err, group_conflict); + return mutations.reload(state, io); +} + +// --------------------------------------------------------------------------- +// routes +// --------------------------------------------------------------------------- + +/// The wire shape of a rule: the row with its enums spelled the way the +/// database spells them. +const RuleView = struct { + id: i64, + group_id: i64, + group: []const u8, + pattern: []const u8, + kind: []const u8, + action: []const u8, + created_at: i64, + + fn from(row: rules_repo.RuleRow) RuleView { + return .{ + .id = row.id, + .group_id = row.group_id, + .group = row.group, + .pattern = row.pattern, + .kind = row.kind.toDb(), + .action = row.action.toDb(), + .created_at = row.created_at, + }; + } +}; + +pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + _ = io; + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return mutations.respondFailure(request, failure, "listing rules"), + }; + + const rows = rules_repo.listRuleRows(database, request.arena) catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "listing rules"); + + const views = try request.arena.alloc(RuleView, rows.items.len); + for (views, rows.items) |*view, row| view.* = .from(row); + + return http_util.respondJson(request, .ok, .{ .rules = views }, &.{}); +} + +pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + _ = io; + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return mutations.respondFailure(request, failure, "reading a rule"), + }; + + const row = rules_repo.getRule(database, request.arena, request.id.?) catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "reading a rule"); + const found = row orelse return mutations.respondFailure(request, .not_found, ""); + + return http_util.respondJson(request, .ok, RuleView.from(found), &.{}); +} + +pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const parsed = http_util.parseBody(Body, request) catch |err| + return mutations.respondBadBody(request, err); + const item = switch (toInput(parsed.value)) { + .fail => |failure| return mutations.respondFailure(request, failure, "creating a rule"), + .input => |value| value, + }; + + return switch (try applyCreate(state, io, request.arena, item)) { + .fail => |failure| mutations.respondFailure(request, failure, "creating a rule"), + .id => |id| http_util.respondJson(request, .created, .{ + .id = id, + .group_id = item.group_id, + .pattern = item.pattern, + .kind = item.kind.toDb(), + .action = item.action.toDb(), + }, &.{}), + }; +} + +pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const parsed = http_util.parseBody(Body, request) catch |err| + return mutations.respondBadBody(request, err); + const item = switch (toInput(parsed.value)) { + .fail => |failure| return mutations.respondFailure(request, failure, "updating a rule"), + .input => |value| value, + }; + const id = request.id.?; + + if (try applyUpdate(state, io, request.arena, id, item)) |failure| { + return mutations.respondFailure(request, failure, "updating a rule"); + } + return http_util.respondJson(request, .ok, .{ + .id = id, + .group_id = item.group_id, + .pattern = item.pattern, + .kind = item.kind.toDb(), + .action = item.action.toDb(), + }, &.{}); +} + +pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + if (applyDelete(state, io, request.id.?)) |failure| { + return mutations.respondFailure(request, failure, "deleting a rule"); + } + return http_util.respondEmpty(request, .no_content); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +const block_ads: rules_repo.RuleInput = .{ + .group_id = 1, + .pattern = "ads.example", + .kind = .exact, + .action = .block, +}; + +test "a created rule is stored with the clock's created_at and reloads" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreate(&bench.state, bench.io(), bench.arena(), block_ads); + try testing.expectEqual(@as(usize, 1), bench.reloads); + + const row = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?; + try testing.expectEqualStrings("ads.example", row.pattern); + try testing.expectEqual(model.RuleAction.block, row.action); + try testing.expectEqualStrings("default", row.group); + try testing.expect(row.created_at > 0); +} + +test "a pattern the validator refuses never reaches the database" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const starred = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ + .group_id = 1, + .pattern = "ads.*.example", + .kind = .exact, + .action = .block, + }); + try testing.expect(starred.fail == .invalid); + + const starless = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ + .group_id = 1, + .pattern = "ads.example", + .kind = .wildcard, + .action = .allow, + }); + try testing.expect(starless.fail == .invalid); + + try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM rules")); + try testing.expectEqual(@as(usize, 0), bench.reloads); +} + +test "a group id no group holds is a conflict" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ + .group_id = 404, + .pattern = "ads.example", + .kind = .exact, + .action = .block, + }); + try testing.expectEqualStrings(group_conflict, created.fail.conflict); + try testing.expectEqual(@as(usize, 0), bench.reloads); +} + +test "an edited rule keeps its created_at" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreate(&bench.state, bench.io(), bench.arena(), block_ads); + const before = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?.created_at; + + const failure = try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, .{ + .group_id = 1, + .pattern = "*.ads.example", + .kind = .wildcard, + .action = .allow, + }); + try testing.expectEqual(@as(?Failure, null), failure); + + const row = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?; + try testing.expectEqualStrings("*.ads.example", row.pattern); + try testing.expectEqual(model.RuleKind.wildcard, row.kind); + try testing.expectEqual(before, row.created_at); + try testing.expectEqual(@as(usize, 2), bench.reloads); +} + +test "an id no rule holds is a 404 on both update and delete" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + try testing.expectEqual( + Failure.not_found, + (try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, block_ads)).?, + ); + try testing.expectEqual(Failure.not_found, applyDelete(&bench.state, bench.io(), 999).?); + try testing.expectEqual(@as(usize, 0), bench.reloads); +} + +test "a deleted rule is gone and the change is announced" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreate(&bench.state, bench.io(), bench.arena(), block_ads); + try testing.expectEqual(@as(?Failure, null), applyDelete(&bench.state, bench.io(), created.id)); + try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM rules")); + try testing.expectEqual(@as(usize, 2), bench.reloads); +} + +test "an unknown kind or action is a 400 before anything is written" { + try testing.expect(toInput(.{ + .group_id = 1, + .pattern = "ads.example", + .kind = "regex", + .action = "block", + }).fail == .invalid); + + try testing.expect(toInput(.{ + .group_id = 1, + .pattern = "ads.example", + .kind = "exact", + .action = "drop", + }).fail == .invalid); + + const good = toInput(.{ + .group_id = 1, + .pattern = "ads.example", + .kind = "wildcard", + .action = "allow", + }); + try testing.expectEqual(model.RuleKind.wildcard, good.input.kind); + try testing.expectEqual(model.RuleAction.allow, good.input.action); +} diff --git a/src/web/handlers/settings.zig b/src/web/handlers/settings.zig new file mode 100644 index 0000000..d9b243c --- /dev/null +++ b/src/web/handlers/settings.zig @@ -0,0 +1,650 @@ +//! `GET`/`PUT /api/settings` — the scalar configuration, the rows of the +//! `settings` table (ruling 16). +//! +//! Everything here is restart-required this milestone, and the response says so +//! for every key: what changes live is the resource endpoints and the pause, +//! not a setting. The list is generated from `model.Config` itself, so a +//! section added to the model appears here without anyone remembering to add +//! it. +//! +//! `web.password` is write-only and `web.password_hash` is neither readable nor +//! directly writable. A PUT carrying `web.password` hashes it with the import +//! path's argon2id parameters and stores the hash alone (PLAN §19: the plain +//! password is never stored, never logged, never echoed). Changing the hash +//! ends every session, because the old cookies were minted under the old +//! password. +//! +//! A PUT is partial: a section left out, or a field left out of a section, keeps +//! what is stored. The merged configuration is validated whole — the same check +//! the next start runs — before a single row is written, so a settings PUT +//! cannot leave a configuration the server would refuse to boot from. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const auth = @import("../auth.zig"); +const db = @import("../../storage/db.zig"); +const http_util = @import("../http_util.zig"); +const model = @import("../../config/model.zig"); +const mutations = @import("mutations.zig"); +const server = @import("../server.zig"); +const settings_repo = @import("../../storage/repositories/settings_repo.zig"); + +const Failure = mutations.Failure; +const Request = http_util.Request; +const HandlerError = http_util.HandlerError; + +const log = std.log.scoped(.web_api); + +/// Holds any PHC-encoded argon2id string comfortably (import.zig's number). +/// Equal to the live holder's capacity by construction, so a hash written here +/// always fits the copy `applyLogin` takes. +const hash_buf_len = auth.LiveHash.max_len; + +/// Fields a client may neither read nor write directly. `password_hash` is +/// derived from `password`; exposing it would let a client install a hash +/// nxdns never computed. +fn isHidden(comptime section: []const u8, comptime field: []const u8) bool { + return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password_hash"); +} + +/// `web.password` is accepted on a PUT and never returned. +fn isWriteOnly(comptime section: []const u8, comptime field: []const u8) bool { + return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password"); +} + +fn isScalarSection(comptime T: type) bool { + return @typeInfo(T) == .@"struct"; +} + +// --------------------------------------------------------------------------- +// the restart-required table (ruling 16) +// --------------------------------------------------------------------------- + +/// Every settings key, in `model.Config` declaration order. Ruling 16: all of +/// them are restart-required this milestone, so the table is the key list and +/// the flag is implied by membership. +pub const restart_required_keys: []const []const u8 = &keys; + +const keys = blk: { + var list: [countKeys()][]const u8 = undefined; + var index = 0; + for (@typeInfo(model.Config).@"struct".fields) |section_field| { + if (!isScalarSection(section_field.type)) continue; + for (@typeInfo(section_field.type).@"struct".fields) |field| { + if (isHidden(section_field.name, field.name)) continue; + if (isWriteOnly(section_field.name, field.name)) continue; + list[index] = section_field.name ++ "." ++ field.name; + index += 1; + } + } + break :blk list; +}; + +fn countKeys() usize { + var count = 0; + for (@typeInfo(model.Config).@"struct".fields) |section_field| { + if (!isScalarSection(section_field.type)) continue; + for (@typeInfo(section_field.type).@"struct".fields) |field| { + if (isHidden(section_field.name, field.name)) continue; + if (isWriteOnly(section_field.name, field.name)) continue; + count += 1; + } + } + return count; +} + +// --------------------------------------------------------------------------- +// the patch a PUT carries +// --------------------------------------------------------------------------- + +/// `Section` with every field optional, so an absent field means "leave it". +/// Generated rather than written out: a hand-copied mirror of `model.Config` +/// would drift the first time a setting is added. +fn Partial(comptime Section: type, comptime section_name: []const u8) type { + const info = @typeInfo(Section).@"struct"; + var names: [info.fields.len][:0]const u8 = undefined; + var types: [info.fields.len]type = undefined; + var attrs: [info.fields.len]std.builtin.Type.StructField.Attributes = undefined; + var count: usize = 0; + for (info.fields) |field| { + if (isHidden(section_name, field.name)) continue; + const Field = ?FieldType(field.type); + const default: Field = null; + names[count] = field.name; + types[count] = Field; + attrs[count] = .{ .default_value_ptr = @ptrCast(&default) }; + count += 1; + } + const final_names = names[0..count].*; + const final_types = types[0..count].*; + const final_attrs = attrs[0..count].*; + return @Struct(.auto, null, &final_names, &final_types, &final_attrs); +} + +/// Enums arrive as the words the database stores, so they are parsed from text +/// rather than by tag name (`logging.level` is `error`, whose tag cannot be). +fn FieldType(comptime T: type) type { + return switch (@typeInfo(T)) { + .@"enum" => []const u8, + else => T, + }; +} + +/// The whole PUT body: every section optional, every field optional. +pub const Patch = blk: { + const config_fields = @typeInfo(model.Config).@"struct".fields; + var names: [config_fields.len][:0]const u8 = undefined; + var types: [config_fields.len]type = undefined; + var attrs: [config_fields.len]std.builtin.Type.StructField.Attributes = undefined; + var count: usize = 0; + for (config_fields) |section_field| { + if (!isScalarSection(section_field.type)) continue; + const Section = ?Partial(section_field.type, section_field.name); + const default: Section = null; + names[count] = section_field.name; + types[count] = Section; + attrs[count] = .{ .default_value_ptr = @ptrCast(&default) }; + count += 1; + } + const final_names = names[0..count].*; + const final_types = types[0..count].*; + const final_attrs = attrs[0..count].*; + break :blk @Struct(.auto, null, &final_names, &final_types, &final_attrs); +}; + +/// Applies `patch` onto `cfg`. A word an enum does not know is the one failure +/// this can report, and it names the key. +fn merge(cfg: *model.Config, patch: Patch, bad_key: *[]const u8) bool { + inline for (@typeInfo(Patch).@"struct".fields) |section_field| { + if (@field(patch, section_field.name)) |section| { + inline for (@typeInfo(@TypeOf(section)).@"struct".fields) |field| { + if (@field(section, field.name)) |value| { + const Target = @TypeOf(@field(@field(cfg, section_field.name), field.name)); + if (@typeInfo(Target) == .@"enum") { + const decoded = Target.fromDb(value) orelse { + bad_key.* = section_field.name ++ "." ++ field.name; + return false; + }; + @field(@field(cfg, section_field.name), field.name) = decoded; + } else { + @field(@field(cfg, section_field.name), field.name) = value; + } + } + } + } + } + return true; +} + +/// Whether the patch carries a new password. +fn newPassword(patch: Patch) ?[]const u8 { + const web = patch.web orelse return null; + const password = web.password orelse return null; + if (password.len == 0) return null; + return password; +} + +// --------------------------------------------------------------------------- +// the read shape +// --------------------------------------------------------------------------- + +const RuntimeView = struct { io_backend: []const u8 }; +const BlockingView = struct { response: []const u8, ttl: u32 }; +const EdnsView = struct { ecs_mode: []const u8 }; + +const LoggingView = struct { + level: []const u8, + retention_days: u16, + query_log_buffer_max: u32, + hide_domains: bool, + hide_client_ips: bool, + output: []const u8, + file_path: []const u8, + max_size_mb: u32, + max_files: u8, +}; + +const WebView = struct { + enabled: bool, + bind: []const u8, + port: u16, + session_ttl_hours: u16, + api_rate_limit_per_min: u32, + api_localhost_exempt: bool, + sse_max_connections_per_ip: u16, + /// Derived, not stored: the hash itself is never serialized, and the UI + /// still has to know whether a password is set. + auth_enabled: bool, +}; + +pub const View = struct { + runtime: RuntimeView, + upstream: model.Upstream, + dns: model.Dns, + blocking: BlockingView, + cache: model.Cache, + web: WebView, + doh_server: model.TlsEndpoint, + dot_server: model.TlsEndpoint, + edns: EdnsView, + logging: LoggingView, + disk: model.Disk, + blocklist_update: model.BlocklistUpdate, +}; + +pub fn view(cfg: model.Config) View { + return .{ + .runtime = .{ .io_backend = cfg.runtime.io_backend.toDb() }, + .upstream = cfg.upstream, + .dns = cfg.dns, + .blocking = .{ .response = cfg.blocking.response.toDb(), .ttl = cfg.blocking.ttl }, + .cache = cfg.cache, + .web = .{ + .enabled = cfg.web.enabled, + .bind = cfg.web.bind, + .port = cfg.web.port, + .session_ttl_hours = cfg.web.session_ttl_hours, + .api_rate_limit_per_min = cfg.web.api_rate_limit_per_min, + .api_localhost_exempt = cfg.web.api_localhost_exempt, + .sse_max_connections_per_ip = cfg.web.sse_max_connections_per_ip, + .auth_enabled = auth.authEnabled(cfg.web), + }, + .doh_server = cfg.doh_server, + .dot_server = cfg.dot_server, + .edns = .{ .ecs_mode = cfg.edns.ecs_mode.toDb() }, + .logging = .{ + .level = cfg.logging.level.toDb(), + .retention_days = cfg.logging.retention_days, + .query_log_buffer_max = cfg.logging.query_log_buffer_max, + .hide_domains = cfg.logging.hide_domains, + .hide_client_ips = cfg.logging.hide_client_ips, + .output = cfg.logging.output.toDb(), + .file_path = cfg.logging.file_path, + .max_size_mb = cfg.logging.max_size_mb, + .max_files = cfg.logging.max_files, + }, + .disk = cfg.disk, + .blocklist_update = cfg.blocklist_update, + }; +} + +// --------------------------------------------------------------------------- +// decisions +// --------------------------------------------------------------------------- + +/// Reads, merges, validates, writes, and — when the password changed — ends +/// every session. Returns the configuration as it now stands. +pub fn applyPut( + state: *server.WebState, + io: std.Io, + arena: Allocator, + patch: Patch, +) error{OutOfMemory}!union(enum) { config: model.Config, fail: Failure } { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return .{ .fail = failure }, + }; + + state.config_lock.lockUncancelable(io); + defer state.config_lock.unlock(io); + + var cfg = mutations.loadConfig(arena, database) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return .{ .fail = .{ .internal = err } }, + }; + + var bad_key: []const u8 = ""; + if (!merge(&cfg, patch, &bad_key)) { + return .{ .fail = .{ .invalid = try std.fmt.allocPrint( + arena, + "{s}: not one of the values this setting accepts", + .{bad_key}, + ) } }; + } + + // The password never becomes a row. It is hashed here and the hash is what + // the merged configuration — and therefore the settings table — carries. + const password = newPassword(patch); + const previous_hash = cfg.web.password_hash; + if (password) |plain| { + if (plain.len > auth.max_password_len) { + return .{ .fail = .{ .invalid = "web.password is too long" } }; + } + const buf = try arena.alloc(u8, hash_buf_len); + cfg.web.password_hash = hashPassword(io, arena, plain, buf) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.Canceled => return .{ .fail = .{ .unavailable = "shutting down" } }, + else => return .{ .fail = .{ .internal = error.Unexpected } }, + }; + } + cfg.web.password = ""; + + if (try problem(arena, cfg)) |text| return .{ .fail = .{ .invalid = text } }; + + // The gpa copy the live holder will own, made before the write so a + // committed transaction can never be followed by a failed revocation. + 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; + + if (writeSettings(arena, database, cfg)) |err| { + if (replacement) |hash| state.gpa.free(hash); + return .{ .fail = .{ .internal = err } }; + } + + if (replacement) |hash| { + // Ruling 17, both halves: the running server must verify against the + // new hash at once — a restart-free credential change — and the + // cookies in flight were minted under the old password. One + // LiveHash-ordered operation: a login with the new password cannot + // mint between the hash swap and the revocation and then lose its + // fresh cookie to it. + state.live_hash.installAndRevoke(io, state.gpa, state.sessions, hash); + } + + return .{ .config = cfg }; +} + +/// Writes every key of `cfg` in one transaction. Rewriting the unchanged rows +/// 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 +/// missed and none can be left behind. +fn writeSettings(arena: Allocator, database: *db.Db, cfg: model.Config) ?db.Error { + var pairs: std.ArrayList(model.SettingPair) = .empty; + model.toSettings(cfg, arena, &pairs) catch return error.OutOfMemory; + + var tx = db.Tx.begin(database) catch |err| return err; + errdefer tx.rollback(); + + for (pairs.items) |pair| { + settings_repo.putSetting(database, pair.key, pair.value) catch |err| { + tx.rollback(); + return err; + }; + } + tx.commit() catch |err| { + tx.rollback(); + return err; + }; + return null; +} + +fn problem(arena: Allocator, cfg: model.Config) error{OutOfMemory}!?[]const u8 { + return mutations.firstProblem(arena, cfg); +} + +/// argon2id with the import path's parameters (OWASP t=2, m=19 MiB, p=1), so a +/// password set through the API and one set through a config import produce the +/// same kind of hash. +fn hashPassword(io: std.Io, gpa: Allocator, password: []const u8, buf: []u8) ![]const u8 { + return std.crypto.pwhash.argon2.strHash(password, .{ + .allocator = gpa, + .params = .owasp_2id, + .mode = .argon2id, + .encoding = .phc, + }, buf, io) catch |err| switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.Canceled => error.Canceled, + else => { + // Never the password, never the hash: only what went wrong. + log.warn("hashing the new web password failed: {s}", .{@errorName(err)}); + return error.Unexpected; + }, + }; +} + +// --------------------------------------------------------------------------- +// routes +// --------------------------------------------------------------------------- + +pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return mutations.respondFailure(request, failure, "reading the settings"), + }; + + // Under the same lock the mutation handlers hold: a PUT rewrites every + // settings row in one transaction on this shared connection, and SQLite's + // own mutex serializes statements, not transactions — an unlocked read + // could see half a PUT. Released before responding, like the mutations. + state.config_lock.lockUncancelable(io); + const loaded = mutations.loadConfig(request.arena, database); + state.config_lock.unlock(io); + + const cfg = loaded catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "reading the settings"); + + return respondSettings(request, .ok, cfg); +} + +pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const parsed = http_util.parseBody(Patch, request) catch |err| + return mutations.respondBadBody(request, err); + + return switch (try applyPut(state, io, request.arena, parsed.value)) { + .fail => |failure| mutations.respondFailure(request, failure, "writing the settings"), + .config => |cfg| respondSettings(request, .ok, cfg), + }; +} + +fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config) HandlerError!void { + return http_util.respondJson(request, status, .{ + .settings = view(cfg), + .restart_required = restart_required_keys, + }, &.{}); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; +const auth_handlers = @import("auth.zig"); + +test "the restart-required table lists every settings key and no secret" { + // `model.toSettings` is the other half of the same fact: the keys the + // database stores, minus the hash the API never serializes. + var pairs: std.ArrayList(model.SettingPair) = .empty; + defer { + model.freeSettings(testing.allocator, pairs.items); + pairs.deinit(testing.allocator); + } + try model.toSettings(.{}, testing.allocator, &pairs); + + try testing.expectEqual(pairs.items.len - 1, restart_required_keys.len); + for (restart_required_keys) |key| { + try testing.expect(!std.mem.eql(u8, key, "web.password_hash")); + try testing.expect(!std.mem.eql(u8, key, "web.password")); + } + + var found_port = false; + for (restart_required_keys) |key| { + if (std.mem.eql(u8, key, "dns.port")) found_port = true; + } + try testing.expect(found_port); +} + +test "the read shape spells every enum the way the database does" { + const rendered = view(.{ + .logging = .{ .level = .err, .output = .file }, + .blocking = .{ .response = .nxdomain }, + .edns = .{ .ecs_mode = .forward }, + .runtime = .{ .io_backend = .evented }, + }); + try testing.expectEqualStrings("error", rendered.logging.level); + try testing.expectEqualStrings("file", rendered.logging.output); + try testing.expectEqualStrings("nxdomain", rendered.blocking.response); + try testing.expectEqualStrings("forward", rendered.edns.ecs_mode); + try testing.expectEqualStrings("evented", rendered.runtime.io_backend); + try testing.expect(!rendered.web.auth_enabled); + + const with_password = view(.{ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$a$b" } }); + try testing.expect(with_password.web.auth_enabled); +} + +test "the patch type has no password_hash field and every field is optional" { + const WebPatch = @typeInfo(@FieldType(Patch, "web")).optional.child; + comptime var has_password = false; + inline for (@typeInfo(WebPatch).@"struct".fields) |field| { + comptime std.debug.assert(@typeInfo(field.type) == .optional); + comptime std.debug.assert(!std.mem.eql(u8, field.name, "password_hash")); + if (comptime std.mem.eql(u8, field.name, "password")) has_password = true; + } + try testing.expect(has_password); +} + +fn seeded(bench: *mutations.Bench) !void { + try bench.exec( + \\INSERT INTO upstreams (url, priority, enabled) VALUES ('https://dns.example/dns-query', 100, 1); + \\INSERT INTO settings (key, value) VALUES ('dns.port', '53'), ('logging.level', 'info'); + ); +} + +test "a partial put changes the keys it names and keeps the rest" { + 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 }; + patch.logging = .{ .level = "debug" }; + + const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch); + try testing.expectEqual(@as(u16, 5353), outcome.config.dns.port); + try testing.expectEqual(model.LogLevel.debug, outcome.config.logging.level); + // Untouched keys keep their stored value, not the model default. + try testing.expectEqual(@as(u32, 1000), outcome.config.dns.rate_limit); + + const stored = try mutations.loadConfig(bench.arena(), &bench.database); + try testing.expectEqual(@as(u16, 5353), stored.dns.port); + try testing.expectEqual(model.LogLevel.debug, stored.logging.level); +} + +test "a put that would not validate writes nothing" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + try seeded(&bench); + + var patch: Patch = .{}; + patch.dns = .{ .port = 0 }; + + const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch); + try testing.expect(outcome.fail == .invalid); + + 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" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + try seeded(&bench); + + var patch: Patch = .{}; + patch.logging = .{ .level = "verbose" }; + + const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch); + try testing.expect(std.mem.startsWith(u8, outcome.fail.invalid, "logging.level:")); +} + +test "a new password is stored as a hash and ends every session" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + try seeded(&bench); + + var sessions: auth.Sessions = .init(24); + bench.state.sessions = &sessions; + const cookie = sessions.createWithToken(bench.io(), @splat(7), 1_000); + try testing.expect(sessions.validateAt(bench.io(), &cookie, 1_001)); + + var patch: Patch = .{}; + patch.web = .{ .password = "correct horse battery staple" }; + + const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch); + try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash, "$argon2id$")); + try testing.expect(!sessions.validateAt(bench.io(), &cookie, 1_001)); + + // The plain password is nowhere in the table, and the hash is. + try testing.expectEqual( + @as(i64, 0), + try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.password'"), + ); + const stored = try mutations.loadConfig(bench.arena(), &bench.database); + try testing.expect(std.mem.startsWith(u8, stored.web.password_hash, "$argon2id$")); + try testing.expectEqual( + auth.Outcome.ok, + try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash, "correct horse battery staple"), + ); +} + +test "changing the password revokes the old one without a restart" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + try seeded(&bench); + + var sessions: auth.Sessions = .init(24); + bench.state.sessions = &sessions; + + var first: Patch = .{}; + first.web = .{ .password = "old password" }; + try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), first) == .config); + + const old_login = auth_handlers.applyLogin(&bench.state, bench.io(), "old password"); + try testing.expect(sessions.validate(bench.io(), &old_login.cookie)); + + var second: Patch = .{}; + second.web = .{ .password = "new password" }; + try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), second) == .config); + + // The session minted under the old password is dead... + try testing.expect(!sessions.validate(bench.io(), &old_login.cookie)); + // ...the old password no longer mints one... + try testing.expect(auth_handlers.applyLogin(&bench.state, bench.io(), "old password").fail == .invalid); + // ...and the new one works immediately, no restart in between. + const new_login = auth_handlers.applyLogin(&bench.state, bench.io(), "new password"); + try testing.expect(sessions.validate(bench.io(), &new_login.cookie)); +} + +test "a put that does not carry a password leaves the sessions alone" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + try seeded(&bench); + + var sessions: auth.Sessions = .init(24); + bench.state.sessions = &sessions; + const cookie = sessions.createWithToken(bench.io(), @splat(9), 1_000); + + var patch: Patch = .{}; + patch.cache = .{ .size = 5000 }; + _ = try applyPut(&bench.state, bench.io(), bench.arena(), patch); + + try testing.expect(sessions.validateAt(bench.io(), &cookie, 1_001)); +} + +test "an empty password is not a password change" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + try seeded(&bench); + + var patch: Patch = .{}; + patch.web = .{ .password = "" }; + + const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch); + try testing.expectEqualStrings("", outcome.config.web.password_hash); +} + +test "reading the settings with no database is unavailable" { + var state: server.WebState = .{ .gpa = testing.allocator }; + var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena_state.deinit(); + + const outcome = try applyPut(&state, undefined, arena_state.allocator(), .{}); + try testing.expect(outcome.fail == .unavailable); +} diff --git a/src/web/handlers/stats.zig b/src/web/handlers/stats.zig new file mode 100644 index 0000000..02c3a8b --- /dev/null +++ b/src/web/handlers/stats.zig @@ -0,0 +1,340 @@ +//! `GET /api/stats` and `GET /api/stats/timeseries` (ruling 13). +//! +//! One period grammar, four widths, and one window shared by both endpoints: +//! the totals cover exactly the span the chart draws, so a dashboard cannot +//! show a sum that disagrees with the bars above it. +//! +//! Buckets are aligned to the UTC grid, not to the moment of the request. Every +//! width divides a day, so flooring the current time to a multiple of the width +//! puts each bucket on the same boundary a human reads off a clock, and two +//! requests a second apart return the same bucket starts. The last bucket is +//! the one in progress; it fills as the period runs. +//! +//! The aggregates run on the web task's own query-log connection (m7 ruling 21). + +const std = @import("std"); + +const db = @import("../../storage/db.zig"); +const http_util = @import("../http_util.zig"); +const queries_repo = @import("../../storage/repositories/queries_repo.zig"); +const server = @import("../server.zig"); + +const log = std.log.scoped(.web_stats); + +/// The four periods ruling 13 defines. The tag names are the wire spellings. +pub const Period = enum { + @"1h", + @"24h", + @"7d", + @"30d", + + pub fn parse(text: []const u8) ?Period { + return std.meta.stringToEnum(Period, text); + } + + /// Ruling 13: 1h→60×1m, 24h→48×30m, 7d→168×1h, 30d→120×6h. + pub fn bucketSeconds(self: Period) u32 { + return switch (self) { + .@"1h" => 60, + .@"24h" => 30 * 60, + .@"7d" => 60 * 60, + .@"30d" => 6 * 60 * 60, + }; + } + + pub fn bucketCount(self: Period) u32 { + return switch (self) { + .@"1h" => 60, + .@"24h" => 48, + .@"7d" => 168, + .@"30d" => 120, + }; + } + + pub fn label(self: Period) []const u8 { + return @tagName(self); + } +}; + +pub const default_period: Period = .@"24h"; + +/// The widest period's bucket count, so one stack array serves every request. +pub const max_buckets = 168; + +comptime { + for (std.enums.values(Period)) |period| { + std.debug.assert(period.bucketCount() <= max_buckets); + // The UTC alignment argument holds only while every width divides a day. + std.debug.assert(86_400 % period.bucketSeconds() == 0); + } +} + +pub const Window = struct { + /// Inclusive, on the bucket grid. + since: i64, + /// Exclusive: the end of the bucket that `now` falls in. + until: i64, + bucket_seconds: u32, + bucket_count: u32, +}; + +pub fn window(period: Period, now_unix: i64) Window { + const width: i64 = period.bucketSeconds(); + const count: i64 = period.bucketCount(); + const until = @divFloor(now_unix, width) * width + width; + return .{ + .since = until - width * count, + .until = until, + .bucket_seconds = period.bucketSeconds(), + .bucket_count = period.bucketCount(), + }; +} + +pub const TotalsBody = struct { + period: []const u8, + since: i64, + until: i64, + queries: u64, + blocked: u64, + cached: u64, + clients: u64, + avg_response_time_us: ?i64, +}; + +pub const TimeseriesBody = struct { + period: []const u8, + since: i64, + until: i64, + bucket_seconds: u32, + buckets: []const queries_repo.Bucket, +}; + +pub fn totals( + state: *server.WebState, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void { + const period = periodParam(request.query) catch return badPeriod(request); + const database = state.querylog_db orelse return unavailable(request); + const span = window(period, std.Io.Clock.real.now(io).toSeconds()); + + const result = queries_repo.statsTotals(database, span.since, span.until) catch |err| { + return internal(request, "stats totals", err); + }; + + return http_util.respondJson(request, .ok, TotalsBody{ + .period = period.label(), + .since = span.since, + .until = span.until, + .queries = result.queries, + .blocked = result.blocked, + .cached = result.cached, + .clients = result.distinct_clients, + .avg_response_time_us = result.avg_response_time_us, + }, &.{}); +} + +pub fn timeseries( + state: *server.WebState, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void { + const period = periodParam(request.query) catch return badPeriod(request); + const database = state.querylog_db orelse return unavailable(request); + const span = window(period, std.Io.Clock.real.now(io).toSeconds()); + + var buckets: [max_buckets]queries_repo.Bucket = undefined; + const out = buckets[0..span.bucket_count]; + const written = queries_repo.timeseries(database, span.since, span.bucket_seconds, out) catch |err| { + return internal(request, "stats timeseries", err); + }; + + return http_util.respondJson(request, .ok, TimeseriesBody{ + .period = period.label(), + .since = span.since, + .until = span.until, + .bucket_seconds = span.bucket_seconds, + .buckets = out[0..written], + }, &.{}); +} + +pub const PeriodError = error{BadPeriod}; + +/// An absent `period` is the default; anything else it cannot read is a 400, +/// never a silent fallback — a typo must not return a window nobody asked for. +pub fn periodParam(query: []const u8) PeriodError!Period { + var buf: [8]u8 = undefined; + const found = http_util.queryValue(query, "period", &buf) catch return error.BadPeriod; + const text = found orelse return default_period; + return Period.parse(text) orelse error.BadPeriod; +} + +fn badPeriod(request: *http_util.Request) http_util.HandlerError!void { + return http_util.respondError(request, .bad_request, "period must be one of 1h, 24h, 7d, 30d"); +} + +fn unavailable(request: *http_util.Request) http_util.HandlerError!void { + return http_util.respondError(request, .service_unavailable, "query log unavailable"); +} + +/// The one thing this file logs. A failed aggregate is a fault in the box, not +/// a property of the request, and the client is told nothing beyond "internal +/// error" (ruling 8, PLAN §19). +fn internal( + request: *http_util.Request, + what: []const u8, + err: db.Error, +) http_util.HandlerError!void { + log.warn("{s} failed: {s}", .{ what, @errorName(err) }); + return http_util.respondError(request, .internal_server_error, "internal error"); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const querylog_schema = @import("../../storage/querylog_schema.zig"); +const testing = std.testing; + +test "the period grammar accepts exactly the four spellings" { + try testing.expectEqual(Period.@"1h", Period.parse("1h").?); + try testing.expectEqual(Period.@"24h", Period.parse("24h").?); + try testing.expectEqual(Period.@"7d", Period.parse("7d").?); + try testing.expectEqual(Period.@"30d", Period.parse("30d").?); + try testing.expectEqual(@as(?Period, null), Period.parse("12h")); + try testing.expectEqual(@as(?Period, null), Period.parse("1H")); + try testing.expectEqual(@as(?Period, null), Period.parse("")); +} + +test "an absent period defaults and a bad one is rejected" { + try testing.expectEqual(default_period, try periodParam("")); + try testing.expectEqual(default_period, try periodParam("limit=5")); + try testing.expectEqual(Period.@"7d", try periodParam("period=7d")); + try testing.expectError(error.BadPeriod, periodParam("period=12h")); + try testing.expectError(error.BadPeriod, periodParam("period=%2")); + // Longer than any spelling: rejected rather than truncated to "1h". + try testing.expectError(error.BadPeriod, periodParam("period=1hhhhhhhhhh")); +} + +test "each period spans its own bucket width times its count" { + for (std.enums.values(Period)) |period| { + const span = window(period, 1_700_000_000); + const width: i64 = period.bucketSeconds(); + try testing.expectEqual(width * @as(i64, period.bucketCount()), span.until - span.since); + } +} + +test "the window sits on the UTC grid and ends with the bucket in progress" { + // 2023-11-14T22:13:20Z, which is not on any bucket boundary. + const now: i64 = 1_700_000_000; + const span = window(.@"24h", now); + + try testing.expectEqual(@as(i64, 0), @rem(span.since, 1800)); + try testing.expectEqual(@as(i64, 0), @rem(span.until, 1800)); + try testing.expect(span.until > now); + try testing.expect(span.until - now <= 1800); + try testing.expectEqual(@as(u32, 48), span.bucket_count); +} + +test "two requests inside one bucket see the same window" { + // A bucket boundary, so the offsets below stay inside one minute. + const boundary: i64 = 1_700_000_000 - @rem(1_700_000_000, 60); + const first = window(.@"1h", boundary); + const second = window(.@"1h", boundary + 59); + try testing.expectEqual(first.since, second.since); + try testing.expectEqual(first.until, second.until); + + const next = window(.@"1h", boundary + 60); + try testing.expectEqual(first.until + 60, next.until); +} + +test "a timestamp exactly on a boundary starts a new bucket" { + const span = window(.@"7d", 1_700_000_000 - 1_700_000_000 % 3600); + try testing.expectEqual(@as(i64, 0), @rem(span.since, 3600)); + try testing.expectEqual(@as(u32, 168), span.bucket_count); +} + +fn openLog() !db.Db { + var database = try db.Db.open(":memory:", .{ .mode = .memory }); + errdefer database.close(); + try db.applyPragmas(&database, .{}); + try database.exec(querylog_schema.ddl); + return database; +} + +fn writeRow(writer: *queries_repo.BatchWriter, timestamp: i64, blocked: bool, cached: ?bool) !void { + const rows = [_]queries_repo.Row{.{ + .timestamp = timestamp, + .domain = "example.com", + .client_ip = "192.0.2.10", + .qtype = 1, + .blocked = blocked, + .block_reason = if (blocked) "blocklist_domain" else null, + .response_time_us = 1000, + .cache_hit = cached, + .upstream = null, + }}; + try writer.writeBatch(&rows); +} + +test "the totals and the buckets agree over the same window" { + var database = try openLog(); + defer database.close(); + + const now: i64 = 1_700_000_000; + const span = window(.@"1h", now); + + var writer = try queries_repo.BatchWriter.init(&database); + defer writer.deinit(); + // One row in the first bucket, two in the last, one just outside. + try writeRow(&writer, span.since, false, false); + try writeRow(&writer, span.until - 1, true, false); + try writeRow(&writer, span.until - 2, false, true); + try writeRow(&writer, span.since - 1, false, false); + + const result = try queries_repo.statsTotals(&database, span.since, span.until); + try testing.expectEqual(@as(u64, 3), result.queries); + try testing.expectEqual(@as(u64, 1), result.blocked); + try testing.expectEqual(@as(u64, 1), result.cached); + try testing.expectEqual(@as(u64, 1), result.distinct_clients); + try testing.expectEqual(@as(?i64, 1000), result.avg_response_time_us); + + var buckets: [max_buckets]queries_repo.Bucket = undefined; + const out = buckets[0..span.bucket_count]; + const written = try queries_repo.timeseries(&database, span.since, span.bucket_seconds, out); + try testing.expectEqual(@as(usize, 60), written); + + var summed: u64 = 0; + var blocked: u64 = 0; + for (out) |bucket| { + summed += bucket.queries; + blocked += bucket.blocked; + } + try testing.expectEqual(result.queries, summed); + try testing.expectEqual(result.blocked, blocked); + + try testing.expectEqual(span.since, out[0].ts); + try testing.expectEqual(@as(u64, 1), out[0].queries); + try testing.expectEqual(@as(u64, 2), out[59].queries); + try testing.expectEqual(span.until - span.bucket_seconds, out[59].ts); +} + +test "an empty window reports zeros with a null mean" { + var database = try openLog(); + defer database.close(); + + const span = window(.@"30d", 1_700_000_000); + const result = try queries_repo.statsTotals(&database, span.since, span.until); + try testing.expectEqual(@as(u64, 0), result.queries); + try testing.expectEqual(@as(?i64, null), result.avg_response_time_us); + + var buckets: [max_buckets]queries_repo.Bucket = undefined; + const out = buckets[0..span.bucket_count]; + try testing.expectEqual(@as(usize, 120), try queries_repo.timeseries( + &database, + span.since, + span.bucket_seconds, + out, + )); + for (out) |bucket| try testing.expectEqual(@as(u64, 0), bucket.queries); +} diff --git a/src/web/handlers/upstream_health.zig b/src/web/handlers/upstream_health.zig new file mode 100644 index 0000000..922a0c6 --- /dev/null +++ b/src/web/handlers/upstream_health.zig @@ -0,0 +1,173 @@ +//! `GET /api/upstream/health` — the pool's own view of its upstreams. +//! +//! The rows are `Pool.Snapshot` with the borrowed strings copied. `last_error` +//! points into the entry that produced it and is rewritten by that entry's next +//! failure, so it is duplicated into the request arena before the pool's mutex +//! is out of sight. +//! +//! No timestamps: the health fields are stamped on the `awake` clock, which +//! stops while the box is suspended and means nothing to a client reading wall +//! time. What an operator needs — is it up, how often does it fail, what did it +//! say last — is here without them. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const http_util = @import("../http_util.zig"); +const metrics = @import("../metrics.zig"); +const pool_mod = @import("../../upstream/pool.zig"); +const server = @import("../server.zig"); + +pub const Upstream = struct { + url: []const u8, + enabled: bool, + available: bool, + consecutive_failures: u32, + total_successes: u64, + total_failures: u64, + success_rate: f32, + /// "" when the upstream has never failed. + last_error: []const u8, +}; + +pub const Body = struct { + upstreams: []const Upstream, + available: u32, + total: u32, +}; + +pub fn handle( + state: *server.WebState, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void { + const pool = state.pool orelse + return http_util.respondError(request, .service_unavailable, "no upstream pool"); + return http_util.respondJson(request, .ok, try collect(pool, io, request.arena), &.{}); +} + +pub fn collect(pool: *pool_mod.Pool, io: std.Io, arena: Allocator) Allocator.Error!Body { + var raw: [metrics.max_upstreams]pool_mod.Snapshot = undefined; + const count = metrics.poolSnapshot(pool, io, &raw); + + const out = try arena.alloc(Upstream, count); + var available: u32 = 0; + for (raw[0..count], out) |entry, *slot| { + if (entry.available) available += 1; + slot.* = .{ + .url = try arena.dupe(u8, entry.url), + .enabled = entry.enabled, + .available = entry.available, + .consecutive_failures = entry.consecutive_failures, + .total_successes = entry.total_successes, + .total_failures = entry.total_failures, + .success_rate = entry.success_rate, + .last_error = try arena.dupe(u8, entry.last_error), + }; + } + + return .{ .upstreams = out, .available = available, .total = @intCast(count) }; +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const transport = @import("../../upstream/transport.zig"); +const testing = std.testing; + +/// The client is never called: every test here reads health, not answers. +fn testEntry(url: []const u8, enabled: bool) pool_mod.Entry { + return .{ + .endpoint = transport.Endpoint.parse(url) catch unreachable, + .client = .{ .ptr = undefined, .exchangeFn = undefined }, + .priority = 1, + .enabled = enabled, + .health = .init, + }; +} + +fn testPool(entries: []pool_mod.Entry) pool_mod.Pool { + return .init(entries, .{}, .{ .raw = .fromMilliseconds(50), .clock = .awake }, 1); +} + +test "every upstream is copied, counted and owned by the arena" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var entries = [_]pool_mod.Entry{ + testEntry("https://a.test/dns-query", true), + testEntry("https://b.test/dns-query", false), + }; + var pool = testPool(&entries); + + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const body = try collect(&pool, io, arena.allocator()); + try testing.expectEqual(@as(u32, 2), body.total); + try testing.expectEqual(@as(usize, 2), body.upstreams.len); + try testing.expectEqualStrings("https://a.test/dns-query", body.upstreams[0].url); + try testing.expect(body.upstreams[0].enabled); + try testing.expect(body.upstreams[0].available); + try testing.expect(!body.upstreams[1].enabled); + try testing.expect(!body.upstreams[1].available); + // A disabled upstream is not available, so it is not counted. + try testing.expectEqual(@as(u32, 1), body.available); + try testing.expectEqualStrings("", body.upstreams[0].last_error); +} + +test "the copied strings survive the entry they came from" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var entries = [_]pool_mod.Entry{testEntry("https://a.test/dns-query", true)}; + var pool = testPool(&entries); + + const at = std.Io.Clock.awake.now(io); + entries[0].health.recordFailure(at, "ConnectFailed", .{}, 0); + + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const body = try collect(&pool, io, arena.allocator()); + try testing.expectEqualStrings("ConnectFailed", body.upstreams[0].last_error); + + // The entry rewrites its buffer; the copy must not change with it. + entries[0].health.recordFailure(at, "Timeout", .{}, 0); + try testing.expectEqualStrings("ConnectFailed", body.upstreams[0].last_error); +} + +test "the body serializes with snake_case field names" { + const upstreams = [_]Upstream{.{ + .url = "https://a.test/dns-query", + .enabled = true, + .available = false, + .consecutive_failures = 3, + .total_successes = 10, + .total_failures = 4, + .success_rate = 0.5, + .last_error = "ConnectFailed", + }}; + + var allocating: std.Io.Writer.Allocating = .init(testing.allocator); + defer allocating.deinit(); + try std.json.Stringify.value( + Body{ .upstreams = &upstreams, .available = 0, .total = 1 }, + .{}, + &allocating.writer, + ); + const text = allocating.written(); + + for ([_][]const u8{ + "\"consecutive_failures\":3", + "\"total_successes\":10", + "\"total_failures\":4", + "\"last_error\":\"ConnectFailed\"", + "\"available\":0", + "\"total\":1", + }) |fragment| { + try testing.expect(std.mem.containsAtLeast(u8, text, 1, fragment)); + } +} diff --git a/src/web/handlers/upstreams.zig b/src/web/handlers/upstreams.zig new file mode 100644 index 0000000..cc1e411 --- /dev/null +++ b/src/web/handlers/upstreams.zig @@ -0,0 +1,365 @@ +//! `/api/upstreams` — the resolvers nxdns forwards to. +//! +//! Ruling 9 makes this a resource like any other; ruling 12 makes it the one +//! mutable resource that is NOT live. The pool builds its clients, its health +//! state and its TLS material at startup, so an upstream added, edited or +//! removed here takes effect at the next restart. The response says so through +//! `restart_required`, which is the same word `/api/settings` uses, so the UI +//! has one banner and one meaning for it. +//! +//! `tls_name` is the DoT-only SNI and certificate name (migration v2). It is +//! empty for every other scheme, and the validator refuses it there. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const http_util = @import("../http_util.zig"); +const model = @import("../../config/model.zig"); +const mutations = @import("mutations.zig"); +const server = @import("../server.zig"); +const upstreams_repo = @import("../../storage/repositories/upstreams_repo.zig"); + +const Failure = mutations.Failure; +const Request = http_util.Request; +const HandlerError = http_util.HandlerError; + +const url_conflict = "an upstream with that url already exists"; + +const Body = struct { + url: []const u8, + priority: i32 = 100, + enabled: bool = true, + tls_name: []const u8 = "", +}; + +const Created = union(enum) { id: i64, fail: Failure }; + +// --------------------------------------------------------------------------- +// decisions +// --------------------------------------------------------------------------- + +pub fn applyCreate( + state: *server.WebState, + io: std.Io, + arena: Allocator, + item: model.UpstreamServer, +) error{OutOfMemory}!Created { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return .{ .fail = failure }, + }; + if (try mutations.checkUpstream(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } }; + + state.config_lock.lockUncancelable(io); + const inserted = upstreams_repo.insertUpstreamRow(database, item); + state.config_lock.unlock(io); + + const id = inserted catch |err| return .{ .fail = mutations.dbFailure(err, url_conflict) }; + return .{ .id = id }; +} + +pub fn applyUpdate( + state: *server.WebState, + io: std.Io, + arena: Allocator, + id: i64, + item: model.UpstreamServer, +) error{OutOfMemory}!?Failure { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return failure, + }; + if (try mutations.checkUpstream(arena, item)) |problem| return .{ .invalid = problem }; + + state.config_lock.lockUncancelable(io); + defer state.config_lock.unlock(io); + + // The same rule `applyDelete` enforces: a set with no enabled upstream + // would refuse to boot, so the write that would create one is a conflict. + if (!item.enabled) { + const remaining = countEnabledExcept(database, arena, id) catch |err| + return mutations.dbFailure(err, url_conflict); + switch (remaining) { + .missing => return .not_found, + .count => |left| if (left == 0) return .{ + .conflict = "the last enabled upstream cannot be disabled", + }, + } + } + + upstreams_repo.updateUpstream(database, id, item) catch |err| + return mutations.dbFailure(err, url_conflict); + return null; +} + +/// The last enabled upstream cannot go: a resolver with nowhere to forward to +/// answers nothing, and `validate.validate` refuses that configuration at +/// startup — so allowing it here would only produce a box that will not boot. +pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure { + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return failure, + }; + + state.config_lock.lockUncancelable(io); + defer state.config_lock.unlock(io); + + const remaining = countEnabledExcept(database, arena, id) catch |err| + return mutations.dbFailure(err, url_conflict); + switch (remaining) { + .missing => return .not_found, + .count => |left| if (left == 0) return .{ + .conflict = "the last enabled upstream cannot be removed", + }, + } + + upstreams_repo.deleteUpstream(database, id) catch |err| + return mutations.dbFailure(err, url_conflict); + return null; +} + +const Remaining = union(enum) { missing, count: usize }; + +fn countEnabledExcept( + database: *@import("../../storage/db.zig").Db, + arena: Allocator, + id: i64, +) @import("../../storage/db.zig").Error!Remaining { + const rows = try upstreams_repo.listUpstreamRows(database, arena); + var found = false; + var left: usize = 0; + for (rows.items) |row| { + if (row.id == id) { + found = true; + continue; + } + if (row.enabled) left += 1; + } + return if (found) .{ .count = left } else .missing; +} + +// --------------------------------------------------------------------------- +// routes +// --------------------------------------------------------------------------- + +pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + _ = io; + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return mutations.respondFailure(request, failure, "listing upstreams"), + }; + + const rows = upstreams_repo.listUpstreamRows(database, request.arena) catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "listing upstreams"); + + return http_util.respondJson(request, .ok, .{ .upstreams = rows.items }, &.{}); +} + +pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + _ = io; + const database = switch (mutations.configDb(state)) { + .database => |value| value, + .fail => |failure| return mutations.respondFailure(request, failure, "reading an upstream"), + }; + + const row = upstreams_repo.getUpstream(database, request.arena, request.id.?) catch |err| + return mutations.respondFailure(request, .{ .internal = err }, "reading an upstream"); + const found = row orelse return mutations.respondFailure(request, .not_found, ""); + + return http_util.respondJson(request, .ok, found, &.{}); +} + +pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const parsed = http_util.parseBody(Body, request) catch |err| + return mutations.respondBadBody(request, err); + const item = toModel(parsed.value); + + return switch (try applyCreate(state, io, request.arena, item)) { + .fail => |failure| mutations.respondFailure(request, failure, "creating an upstream"), + .id => |id| http_util.respondJson(request, .created, .{ + .id = id, + .url = item.url, + .priority = item.priority, + .enabled = item.enabled, + .tls_name = item.tls_name, + .restart_required = true, + }, &.{}), + }; +} + +pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const parsed = http_util.parseBody(Body, request) catch |err| + return mutations.respondBadBody(request, err); + const item = toModel(parsed.value); + const id = request.id.?; + + if (try applyUpdate(state, io, request.arena, id, item)) |failure| { + return mutations.respondFailure(request, failure, "updating an upstream"); + } + return http_util.respondJson(request, .ok, .{ + .id = id, + .url = item.url, + .priority = item.priority, + .enabled = item.enabled, + .tls_name = item.tls_name, + .restart_required = true, + }, &.{}); +} + +pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + if (applyDelete(state, io, request.arena, request.id.?)) |failure| { + return mutations.respondFailure(request, failure, "deleting an upstream"); + } + return http_util.respondEmpty(request, .no_content); +} + +fn toModel(body: Body) model.UpstreamServer { + return .{ + .url = body.url, + .priority = body.priority, + .enabled = body.enabled, + .tls_name = body.tls_name, + }; +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +const doh: model.UpstreamServer = .{ .url = "https://dns.example/dns-query" }; + +test "a created upstream is stored" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh); + const row = (try upstreams_repo.getUpstream(&bench.database, bench.arena(), created.id)).?; + try testing.expectEqualStrings(doh.url, row.url); + try testing.expect(row.enabled); + try testing.expectEqualStrings("", row.tls_name); +} + +test "an upstream change never announces a reload" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh); + _ = try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, .{ + .url = doh.url, + .priority = 50, + .enabled = true, + }); + // Ruling 12: the pool is built at startup, so nothing is live to reload. + try testing.expectEqual(@as(usize, 0), bench.reloads); +} + +test "a url the validator refuses never reaches the database" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const scheme = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .url = "udp://1.1.1.1:53" }); + try testing.expect(scheme.fail == .invalid); + + const misplaced_name = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ + .url = "https://dns.example/dns-query", + .tls_name = "dns.example", + }); + try testing.expect(misplaced_name.fail == .invalid); + + try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM upstreams")); +} + +test "a duplicate url is a conflict" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + _ = try applyCreate(&bench.state, bench.io(), bench.arena(), doh); + const again = try applyCreate(&bench.state, bench.io(), bench.arena(), doh); + try testing.expectEqualStrings(url_conflict, again.fail.conflict); +} + +test "the last enabled upstream cannot be deleted" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh); + const failure = applyDelete(&bench.state, bench.io(), bench.arena(), created.id); + try testing.expectEqualStrings("the last enabled upstream cannot be removed", failure.?.conflict); + + const second = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ + .url = "tls://1.1.1.1:853", + .tls_name = "one.one.one.one", + }); + try testing.expectEqual( + @as(?Failure, null), + applyDelete(&bench.state, bench.io(), bench.arena(), created.id), + ); + try testing.expectEqual( + @as(i64, 1), + try bench.queryInt("SELECT count(*) FROM upstreams"), + ); + try testing.expect(second == .id); +} + +test "a disabled upstream can be created while an enabled one exists" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + _ = try applyCreate(&bench.state, bench.io(), bench.arena(), doh); + const spare = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ + .url = "tls://1.1.1.1:853", + .tls_name = "one.one.one.one", + .enabled = false, + }); + try testing.expect(spare == .id); + try testing.expectEqual( + @as(i64, 0), + try bench.queryInt("SELECT enabled FROM upstreams WHERE url = 'tls://1.1.1.1:853'"), + ); +} + +test "the last enabled upstream cannot be disabled" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh); + const off: model.UpstreamServer = .{ .url = doh.url, .enabled = false }; + + const refused = try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, off); + try testing.expectEqualStrings("the last enabled upstream cannot be disabled", refused.?.conflict); + try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM upstreams WHERE enabled = 1")); + + _ = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ + .url = "tls://1.1.1.1:853", + .tls_name = "one.one.one.one", + }); + try testing.expectEqual( + @as(?Failure, null), + try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, off), + ); + try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM upstreams WHERE enabled = 1")); +} + +test "an id no upstream holds is a 404 on both update and delete" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + + try testing.expectEqual( + Failure.not_found, + (try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, doh)).?, + ); + try testing.expectEqual( + Failure.not_found, + applyDelete(&bench.state, bench.io(), bench.arena(), 999).?, + ); +} diff --git a/src/web/handlers/version.zig b/src/web/handlers/version.zig new file mode 100644 index 0000000..ea6bb8f --- /dev/null +++ b/src/web/handlers/version.zig @@ -0,0 +1,72 @@ +//! `GET /api/version` — what this binary is and how long it has been running. +//! +//! Unauthenticated (ruling 18), like the other monitoring endpoints. The three +//! strings are build options, so nothing here reads the running configuration. + +const std = @import("std"); + +const http_util = @import("../http_util.zig"); +const server = @import("../server.zig"); +const version = @import("../../version.zig"); + +pub const Body = struct { + version: []const u8, + git_commit: []const u8, + zig_version: []const u8, + /// Seconds since the process started. Zero until `started_unix` is wired, + /// and never negative: a clock stepped backwards must not report a + /// process that started in the future. + uptime_seconds: u64, +}; + +pub fn handle( + state: *server.WebState, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void { + const now = std.Io.Clock.real.now(io).toSeconds(); + return http_util.respondJson(request, .ok, body(state.version, state.started_unix, now), &.{}); +} + +pub fn body(version_string: []const u8, started_unix: i64, now_unix: i64) Body { + return .{ + .version = if (version_string.len == 0) version.string else version_string, + .git_commit = version.git_commit, + .zig_version = version.zig_version_string, + .uptime_seconds = uptime(started_unix, now_unix), + }; +} + +fn uptime(started_unix: i64, now_unix: i64) u64 { + if (started_unix <= 0 or now_unix <= started_unix) return 0; + return @intCast(now_unix - started_unix); +} + +const testing = std.testing; + +test "the body carries the build strings and the elapsed time" { + const out = body("", 1_000, 1_060); + try testing.expectEqualStrings(version.string, out.version); + try testing.expectEqualStrings(version.git_commit, out.git_commit); + try testing.expectEqualStrings(version.zig_version_string, out.zig_version); + try testing.expectEqual(@as(u64, 60), out.uptime_seconds); +} + +test "the state's version string wins over the compiled-in one" { + try testing.expectEqualStrings("9.9.9", body("9.9.9", 0, 0).version); +} + +test "an unset start time and a clock that stepped back both read as zero uptime" { + try testing.expectEqual(@as(u64, 0), body("", 0, 5_000).uptime_seconds); + try testing.expectEqual(@as(u64, 0), body("", 5_000, 4_000).uptime_seconds); +} + +test "the body serializes with snake_case field names" { + var buffer: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + try std.json.Stringify.value(body("1.2.3", 10, 20), .{}, &writer); + const text = writer.buffered(); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"git_commit\":")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"zig_version\":")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"uptime_seconds\":10")); +} diff --git a/src/web/http_util.zig b/src/web/http_util.zig new file mode 100644 index 0000000..580893b --- /dev/null +++ b/src/web/http_util.zig @@ -0,0 +1,454 @@ +//! The HTTP plumbing every web handler shares: the request view the router +//! builds, JSON and error responses, the capped body reader, and the pure +//! target/cookie parsers. +//! +//! Pure apart from the response helpers, which need the live `std.http.Server` +//! request. No sockets, no clock, no database. +//! +//! Two traps shape this file: +//! +//! - `Request.head.target` and every header string are invalidated the moment +//! the body stream is initialised (http/Server.zig:594 calls +//! `head.invalidateStrings`, Server.zig:230 documents it). Everything a +//! handler may need after a body read is therefore copied out of the head +//! before dispatch, into buffers the connection slot owns. +//! - `std.Uri.percentDecodeInPlace` is lenient: a truncated or non-hex escape +//! is copied through as literal text. PLAN §19 wants malformed input +//! rejected, not forwarded to a query, so this file decodes itself and +//! returns `error.BadEscape`. + +const std = @import("std"); +const http = std.http; +const net = std.Io.net; +const Allocator = std.mem.Allocator; + +/// Ruling 7. A request body larger than this is refused with 413 rather than +/// buffered: every body this API accepts is a small JSON document. +pub const max_body_bytes: usize = 1 << 20; + +/// The request line's target, path plus query. The receive buffer is 8 KiB, so +/// a longer target cannot arrive intact anyway; a target over this is 414. +pub const max_target_len: usize = 2048; + +/// A cookie header holding one session cookie is ~60 bytes. The rest of the +/// budget absorbs whatever else the browser sends for the origin. +pub const max_cookie_len: usize = 1024; + +/// `accept-encoding` and `if-none-match` are the only other headers the layer +/// reads. Both are short; an over-long one is treated as absent. +pub const max_header_value_len: usize = 128; + +/// A path deeper than this matches no route, so parsing can stop there. +pub const max_path_segments: usize = 8; + +/// Query values are single domains, integers, booleans and timestamps. A longer +/// one is a 400, never a truncation. +pub const max_query_value_len: usize = 512; + +pub const content_type_json = "application/json"; +pub const content_type_text = "text/plain; charset=utf-8"; + +/// What a handler may fail with. Everything domain-specific — a missing row, a +/// bad body, a database error — is the handler's job to turn into a status code +/// (ruling 8); only these three escape. +pub const HandlerError = error{ + /// The client went away mid-response. Ruling 28: end the connection quietly. + WriteFailed, + /// The client sent an `expect` header nxdns does not implement. + HttpExpectationFailed, + OutOfMemory, +}; + +/// The path of a request, split into segments and percent-decoded. +/// +/// Segments are split before they are decoded, so `%2F` inside a segment stays +/// inside it and cannot forge a path boundary. Empty segments are dropped, so +/// `/api/groups/` and `/api//groups` both read as `api`, `groups`. +pub const Path = struct { + buf: [max_path_segments][]const u8, + len: usize, + + pub const empty: Path = .{ .buf = undefined, .len = 0 }; + + pub fn segments(self: *const Path) []const []const u8 { + return self.buf[0..self.len]; + } +}; + +pub const PathError = error{ BadEscape, TooManySegments }; + +/// Decodes `buffer` in place. The returned `Path` borrows from it. +pub fn parsePath(buffer: []u8) PathError!Path { + var path: Path = .empty; + var rest = buffer; + while (rest.len != 0) { + const end = std.mem.findScalar(u8, rest, '/') orelse rest.len; + const raw = rest[0..end]; + rest = if (end == rest.len) rest[end..] else rest[end + 1 ..]; + if (raw.len == 0) continue; + if (path.len == max_path_segments) return error.TooManySegments; + path.buf[path.len] = try decodeInPlace(raw, .literal_plus); + path.len += 1; + } + return path; +} + +/// Whether `+` means a space. It does in a query string (form encoding) and +/// does not in a path, where it is an ordinary character. +pub const PlusRule = enum { literal_plus, plus_is_space }; + +pub const DecodeError = error{BadEscape}; + +/// Percent-decodes `buffer` in place and returns the shortened slice. Decoding +/// only ever shrinks, so the write cursor never passes the read cursor. +pub fn decodeInPlace(buffer: []u8, plus: PlusRule) DecodeError![]u8 { + var read: usize = 0; + var write: usize = 0; + while (read < buffer.len) : (write += 1) { + const c = buffer[read]; + if (c == '%') { + if (read + 3 > buffer.len) return error.BadEscape; + const hi = hexDigit(buffer[read + 1]) orelse return error.BadEscape; + const lo = hexDigit(buffer[read + 2]) orelse return error.BadEscape; + buffer[write] = hi * 16 + lo; + read += 3; + } else if (c == '+' and plus == .plus_is_space) { + buffer[write] = ' '; + read += 1; + } else { + buffer[write] = c; + read += 1; + } + } + return buffer[0..write]; +} + +fn hexDigit(c: u8) ?u8 { + return switch (c) { + '0'...'9' => c - '0', + 'a'...'f' => c - 'a' + 10, + 'A'...'F' => c - 'A' + 10, + else => null, + }; +} + +pub const Pair = struct { + /// Still percent-encoded. Every key this API defines is plain ASCII, so + /// keys are compared raw and only values are decoded. + key: []const u8, + value: []const u8, +}; + +/// Walks `key=value` pairs separated by `&`. A pair without `=` yields an empty +/// value; an empty pair is skipped. +pub const PairIterator = struct { + rest: []const u8, + + pub fn next(self: *PairIterator) ?Pair { + while (self.rest.len != 0) { + const end = std.mem.findScalar(u8, self.rest, '&') orelse self.rest.len; + const raw = self.rest[0..end]; + self.rest = if (end == self.rest.len) self.rest[end..] else self.rest[end + 1 ..]; + if (raw.len == 0) continue; + const eq = std.mem.findScalar(u8, raw, '=') orelse return .{ .key = raw, .value = "" }; + return .{ .key = raw[0..eq], .value = raw[eq + 1 ..] }; + } + return null; + } +}; + +pub fn queryPairs(query: []const u8) PairIterator { + return .{ .rest = query }; +} + +pub const QueryError = error{ BadEscape, ValueTooLong }; + +/// Copies the value of `key` into `out`, decodes it there, and returns the +/// decoded slice. `null` means the key is absent. A value that does not fit +/// `out` is `error.ValueTooLong`, which the caller answers with 400 — it is +/// never silently truncated. +pub fn queryValue(query: []const u8, key: []const u8, out: []u8) QueryError!?[]u8 { + var it = queryPairs(query); + while (it.next()) |pair| { + if (!std.mem.eql(u8, pair.key, key)) continue; + if (pair.value.len > out.len) return error.ValueTooLong; + @memcpy(out[0..pair.value.len], pair.value); + return try decodeInPlace(out[0..pair.value.len], .plus_is_space); + } + return null; +} + +pub const QueryIntError = QueryError || error{BadValue}; + +/// The whole decoded value must parse, so `?limit=10x` is a 400 rather than 10. +pub fn queryInt(comptime T: type, query: []const u8, key: []const u8) QueryIntError!?T { + var buf: [max_query_value_len]u8 = undefined; + const text = try queryValue(query, key, &buf) orelse return null; + return std.fmt.parseInt(T, text, 10) catch error.BadValue; +} + +/// Accepts the four spellings a browser query string realistically carries. +pub fn queryBool(query: []const u8, key: []const u8) QueryIntError!?bool { + var buf: [max_query_value_len]u8 = undefined; + const text = try queryValue(query, key, &buf) orelse return null; + if (std.mem.eql(u8, text, "true") or std.mem.eql(u8, text, "1")) return true; + if (std.mem.eql(u8, text, "false") or std.mem.eql(u8, text, "0")) return false; + return error.BadValue; +} + +/// Reads one cookie out of a `cookie` header value. Returns a slice of `header`. +pub fn cookieValue(header: []const u8, name: []const u8) ?[]const u8 { + var rest = header; + while (rest.len != 0) { + const end = std.mem.findScalar(u8, rest, ';') orelse rest.len; + var pair = rest[0..end]; + rest = if (end == rest.len) rest[end..] else rest[end + 1 ..]; + pair = std.mem.trim(u8, pair, " \t"); + const eq = std.mem.findScalar(u8, pair, '=') orelse continue; + if (std.mem.eql(u8, pair[0..eq], name)) return pair[eq + 1 ..]; + } + return null; +} + +/// Ruling 17: `Secure` is deliberately absent. nxdns serves plain HTTP on the +/// LAN and TLS termination is the operator's proxy; setting `Secure` would make +/// the cookie unusable in the configuration nxdns actually ships. +/// +/// `max_age_seconds` of null writes a session cookie; 0 deletes it. +pub fn formatSetCookie( + buf: []u8, + name: []const u8, + value: []const u8, + max_age_seconds: ?i64, +) error{NoSpace}![]const u8 { + var writer: std.Io.Writer = .fixed(buf); + writer.print("{s}={s}; HttpOnly; SameSite=Lax; Path=/", .{ name, value }) catch return error.NoSpace; + if (max_age_seconds) |age| { + writer.print("; Max-Age={d}", .{age}) catch return error.NoSpace; + } + return writer.buffered(); +} + +/// Everything a handler is allowed to know about the request. The router builds +/// it once per request, before any body read, out of buffers the connection +/// slot owns (see this file's header for why). +pub const Request = struct { + /// The live request, for responding and for reading the body. + http: *http.Server.Request, + method: http.Method, + /// Decoded path segments. + path: Path, + /// The raw target's path part, undecoded, for exact asset matching. + raw_path: []const u8, + /// The raw query string, without the `?`. Values are decoded on demand. + query: []const u8, + /// The `{id}` capture of the matched route, when it had one. + id: ?i64, + cookie: []const u8, + accept_encoding: []const u8, + if_none_match: []const u8, + peer: net.IpAddress, + /// Reset between requests on the same connection. Nothing allocated here + /// survives the response. + arena: Allocator, + + pub fn firstSegment(self: *const Request) []const u8 { + return if (self.path.len == 0) "" else self.path.buf[0]; + } +}; + +pub const BodyError = error{ + OutOfMemory, + /// Over `max_body_bytes` — ruling 8's 413. + TooLarge, + /// The peer stopped sending. The connection ends. + ReadFailed, + HttpExpectationFailed, + WriteFailed, +}; + +/// Reads the whole request body, capped. Callable once per request: the +/// underlying reader is initialised on first use. +pub fn readBody(request: *Request) BodyError![]u8 { + const staging = try request.arena.alloc(u8, 4096); + const reader = try request.http.readerExpectContinue(staging); + return reader.allocRemaining(request.arena, .limited(max_body_bytes)) catch |err| switch (err) { + error.StreamTooLong => error.TooLarge, + error.OutOfMemory => error.OutOfMemory, + error.ReadFailed => error.ReadFailed, + }; +} + +/// Parses the body as `T`. Unknown fields are rejected so a typo in a PUT is a +/// 400 rather than a silently ignored field. +pub fn parseBody(comptime T: type, request: *Request) (BodyError || error{BadJson})!std.json.Parsed(T) { + const bytes = try readBody(request); + return std.json.parseFromSlice(T, request.arena, bytes, .{ + .ignore_unknown_fields = false, + }) catch error.BadJson; +} + +/// Ruling 8's envelope. `message` is operator-facing text, never a raw internal +/// error string for a 500 (PLAN §19: details go to the log, not the wire). +pub fn respondError( + request: *Request, + status: http.Status, + message: []const u8, +) HandlerError!void { + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + var stringify: std.json.Stringify = .{ .writer = &writer }; + stringify.beginObject() catch return respondPlain(request, status, message); + stringify.objectField("error") catch return respondPlain(request, status, message); + stringify.write(message) catch return respondPlain(request, status, message); + stringify.endObject() catch return respondPlain(request, status, message); + return respondBytes(request, status, writer.buffered(), content_type_json, &.{}); +} + +fn respondPlain(request: *Request, status: http.Status, message: []const u8) HandlerError!void { + return respondBytes(request, status, message, content_type_text, &.{}); +} + +/// Serialises `value` and responds. The document is built in the request arena +/// so `respond` can send a content-length rather than chunking. +pub fn respondJson( + request: *Request, + status: http.Status, + value: anytype, + extra_headers: []const http.Header, +) HandlerError!void { + var allocating: std.Io.Writer.Allocating = .init(request.arena); + defer allocating.deinit(); + std.json.Stringify.value(value, .{}, &allocating.writer) catch return error.OutOfMemory; + return respondBytes(request, status, allocating.written(), content_type_json, extra_headers); +} + +pub fn respondBytes( + request: *Request, + status: http.Status, + body: []const u8, + content_type: []const u8, + extra_headers: []const http.Header, +) HandlerError!void { + var headers: [8]http.Header = undefined; + headers[0] = .{ .name = "content-type", .value = content_type }; + if (extra_headers.len + 1 > headers.len) return error.OutOfMemory; + @memcpy(headers[1 .. 1 + extra_headers.len], extra_headers); + return request.http.respond(body, .{ + .status = status, + .extra_headers = headers[0 .. 1 + extra_headers.len], + }); +} + +/// 204: no body, no content-type. +pub fn respondEmpty(request: *Request, status: http.Status) HandlerError!void { + return request.http.respond("", .{ .status = status }); +} + +const testing = std.testing; + +test "a path splits into segments and decodes each one" { + var buf = "/api/groups/12".*; + const path = try parsePath(&buf); + try testing.expectEqual(@as(usize, 3), path.len); + try testing.expectEqualStrings("api", path.buf[0]); + try testing.expectEqualStrings("groups", path.buf[1]); + try testing.expectEqualStrings("12", path.buf[2]); +} + +test "empty segments collapse so a trailing slash changes nothing" { + var with = "/api//groups/".*; + const a = try parsePath(&with); + var without = "/api/groups".*; + const b = try parsePath(&without); + try testing.expectEqual(b.len, a.len); + try testing.expectEqualStrings(b.buf[1], a.buf[1]); +} + +test "an encoded slash stays inside its segment" { + var buf = "/api/rules/a%2Fb".*; + const path = try parsePath(&buf); + try testing.expectEqual(@as(usize, 3), path.len); + try testing.expectEqualStrings("a/b", path.buf[2]); +} + +test "a path deeper than the segment budget is refused" { + var buf = "/1/2/3/4/5/6/7/8/9".*; + try testing.expectError(error.TooManySegments, parsePath(&buf)); +} + +test "a plus in a path is a literal plus" { + var buf = "/a+b".*; + const path = try parsePath(&buf); + try testing.expectEqualStrings("a+b", path.buf[0]); +} + +test "a plus in a query value is a space" { + var out: [16]u8 = undefined; + const value = try queryValue("domain=a+b", "domain", &out) orelse return error.TestUnexpectedResult; + try testing.expectEqualStrings("a b", value); +} + +test "a truncated escape is rejected rather than passed through" { + var out: [16]u8 = undefined; + try testing.expectError(error.BadEscape, queryValue("domain=%2", "domain", &out)); + try testing.expectError(error.BadEscape, queryValue("domain=%", "domain", &out)); + try testing.expectError(error.BadEscape, queryValue("domain=%zz", "domain", &out)); +} + +test "an over-long query value is rejected rather than truncated" { + var out: [4]u8 = undefined; + try testing.expectError(error.ValueTooLong, queryValue("domain=abcde", "domain", &out)); +} + +test "query pairs tolerate empty pairs and missing values" { + var it = queryPairs("a=1&&b&c="); + try testing.expectEqualStrings("a", it.next().?.key); + const b = it.next().?; + try testing.expectEqualStrings("b", b.key); + try testing.expectEqualStrings("", b.value); + const c = it.next().?; + try testing.expectEqualStrings("c", c.key); + try testing.expectEqualStrings("", c.value); + try testing.expectEqual(@as(?Pair, null), it.next()); +} + +test "an absent query key reads as null, not as an error" { + var out: [16]u8 = undefined; + try testing.expectEqual(@as(?[]u8, null), try queryValue("a=1", "b", &out)); + try testing.expectEqual(@as(?u32, null), try queryInt(u32, "a=1", "b")); +} + +test "typed query values parse and reject" { + try testing.expectEqual(@as(?u32, 250), try queryInt(u32, "limit=250", "limit")); + try testing.expectError(error.BadValue, queryInt(u32, "limit=10x", "limit")); + try testing.expectEqual(@as(?bool, true), try queryBool("blocked=1", "blocked")); + try testing.expectEqual(@as(?bool, false), try queryBool("blocked=false", "blocked")); + try testing.expectError(error.BadValue, queryBool("blocked=maybe", "blocked")); +} + +test "a formatted cookie parses back to the same value" { + var buf: [128]u8 = undefined; + const header = try formatSetCookie(&buf, "nxdns_session", "abcDEF-_", null); + try testing.expectEqualStrings("nxdns_session=abcDEF-_; HttpOnly; SameSite=Lax; Path=/", header); + + const cookie_header = "other=1; nxdns_session=abcDEF-_; last=2"; + try testing.expectEqualStrings("abcDEF-_", cookieValue(cookie_header, "nxdns_session").?); +} + +test "a deleting cookie carries a zero max age" { + var buf: [128]u8 = undefined; + const header = try formatSetCookie(&buf, "nxdns_session", "", 0); + try testing.expectEqualStrings("nxdns_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0", header); +} + +test "a cookie header without the wanted name reads as absent" { + try testing.expectEqual(@as(?[]const u8, null), cookieValue("a=1; b=2", "nxdns_session")); + try testing.expectEqual(@as(?[]const u8, null), cookieValue("", "nxdns_session")); + try testing.expectEqual(@as(?[]const u8, null), cookieValue("novalue", "novalue")); +} + +test "a set-cookie longer than its buffer fails instead of truncating" { + var buf: [8]u8 = undefined; + try testing.expectError(error.NoSpace, formatSetCookie(&buf, "nxdns_session", "x", null)); +} diff --git a/src/web/metrics.zig b/src/web/metrics.zig new file mode 100644 index 0000000..49d36e6 --- /dev/null +++ b/src/web/metrics.zig @@ -0,0 +1,611 @@ +//! `GET /metrics` — Prometheus text format 0.0.4 (ruling 21). +//! +//! Two halves, so that neither needs the other to be testable: `collect` walks +//! the live collaborators and copies every number into a `Sample`, and `render` +//! turns a `Sample` into text. Nothing is computed during rendering. +//! +//! Three rules the collection half obeys: +//! +//! - The cache and the DNS rate limiter are the query path's, so their numbers +//! are read under the handler's own mutexes. Both sections are a struct copy +//! long. `lockUncancelable` because a handler carries no `Canceled`. +//! - Every borrowed string is copied on the spot. `Pool.Snapshot.last_error` +//! and `url` point into entries a concurrent failure may rewrite. +//! - A collaborator that is not wired omits its whole metric family rather than +//! reporting zeros. An absent series is a gap a dashboard can see; a zero is +//! a lie that looks like health. +//! +//! No timestamps: Prometheus stamps a scrape with its own clock, and the +//! optional per-sample timestamp is for federation, which nxdns does not do. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const clients = @import("../server/clients.zig"); +const dns_cache = @import("../cache/dns_cache.zig"); +const dns_handler = @import("../server/handler.zig"); +const disk_monitor = @import("../storage/disk_monitor.zig"); +const http_util = @import("http_util.zig"); +const logging = @import("../platform/logging.zig"); +const pool_mod = @import("../upstream/pool.zig"); +const rate_limiter = @import("../server/rate_limiter.zig"); +const retention_mod = @import("../storage/retention.zig"); +const server = @import("server.zig"); + +/// The exposition format version, as the 0.0.4 specification writes it. +pub const content_type = "text/plain; version=0.0.4; charset=utf-8"; + +/// Upstreams copied per scrape. `validate.zig` bounds a configuration far below +/// this; a pool larger than the buffer is truncated rather than allocated for, +/// because a scrape must not depend on the heap. +pub const max_upstreams = 64; + +const dns_stat_fields = @typeInfo(dns_handler.Handler.Stats).@"struct".fields; + +/// The DNS pipeline counters, in `Handler.Stats` field order. Held as an array +/// so that a new counter in the handler appears here, and in the exposition, +/// without an edit. +pub const DnsCounters = [dns_stat_fields.len]u64; + +pub const LoggerCounters = struct { + queries_dropped: u64 = 0, + rows_written: u64 = 0, + batches_gated: u64 = 0, +}; + +pub const CacheSample = struct { + stats: dns_cache.Stats, + entries: u64, + memory_bytes: u64, +}; + +pub const LimiterSample = struct { + stats: rate_limiter.Stats, + tracked_clients: u64, +}; + +pub const TrackerSample = struct { + stats: clients.Tracker.Stats, + pending_clients: u64, +}; + +pub const BlocklistSample = struct { + refreshes_gated: u64, + /// Null before the first snapshot is published. + generation: ?u64, +}; + +pub const DiskSample = struct { + gauges: disk_monitor.Gauges, + sample_failures: u64, +}; + +/// One upstream, with every string owned by the caller's arena. +pub const UpstreamSample = struct { + url: []const u8, + enabled: bool, + available: bool, + consecutive_failures: u64, + total_successes: u64, + total_failures: u64, + success_rate: f32, +}; + +/// Everything one scrape reports. A null section is a collaborator the state +/// does not have. +pub const Sample = struct { + dns: DnsCounters = @splat(0), + logger: LoggerCounters = .{}, + log_sink: logging.Stats = .{}, + cache: ?CacheSample = null, + limiter: ?LimiterSample = null, + tracker: ?TrackerSample = null, + retention: ?retention_mod.Stats = null, + blocklist: ?BlocklistSample = null, + disk: ?DiskSample = null, + upstreams: []const UpstreamSample = &.{}, +}; + +pub fn handle( + state: *server.WebState, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void { + const sample = try collect(state, io, request.arena); + + var allocating: std.Io.Writer.Allocating = .init(request.arena); + defer allocating.deinit(); + render(&allocating.writer, sample) catch return error.OutOfMemory; + + return http_util.respondBytes(request, .ok, allocating.written(), content_type, &.{}); +} + +pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.Error!Sample { + var sample: Sample = .{ .log_sink = logging.stats() }; + + if (state.handler) |handler| { + sample.dns = dnsCounters(&handler.stats); + + if (handler.cache) |cache| { + handler.cache_mutex.lockUncancelable(io); + defer handler.cache_mutex.unlock(io); + sample.cache = .{ + .stats = cache.stats, + .entries = cache.len(), + .memory_bytes = cache.memoryBytes(), + }; + } + + if (handler.limiter) |limiter| { + handler.limiter_mutex.lockUncancelable(io); + defer handler.limiter_mutex.unlock(io); + sample.limiter = .{ .stats = limiter.stats, .tracked_clients = limiter.table.count() }; + } + } + + if (state.logger) |logger| sample.logger = .{ + .queries_dropped = logger.queries_dropped.load(.monotonic), + .rows_written = logger.rows_written.load(.monotonic), + .batches_gated = logger.batches_gated.load(.monotonic), + }; + + if (state.tracker) |tracker| sample.tracker = .{ + .stats = tracker.snapshotStats(io), + .pending_clients = tracker.pendingClients(io), + }; + + if (state.retention) |retention| sample.retention = retention.snapshotStats(); + + if (state.manager) |manager| { + const generation: ?u64 = if (manager.acquire(io)) |acquired| gen: { + defer acquired.release(io); + break :gen acquired.snapshot.generation; + } else null; + sample.blocklist = .{ .refreshes_gated = manager.refreshesGated(), .generation = generation }; + } + + if (state.monitor) |monitor| sample.disk = .{ + .gauges = monitor.gauges(), + .sample_failures = monitor.sample_failures.load(.monotonic), + }; + + if (state.pool) |pool| sample.upstreams = try upstreams(pool, io, arena); + + return sample; +} + +fn dnsCounters(stats: *const dns_handler.Handler.Stats) DnsCounters { + var out: DnsCounters = undefined; + inline for (dns_stat_fields, 0..) |field, i| { + out[i] = @field(stats, field.name).load(.monotonic); + } + return out; +} + +/// Copies pool health into `out` and returns the count. +/// +/// `Pool.snapshot` takes the pool's mutex and copies structs, so it blocks only +/// on other snapshots. Cancellation is held off for the length of that copy: +/// the alternative is a report of no upstreams at all because the connection +/// happened to be closing, which reads as an outage. Shared with the health +/// rollup, which needs the same copy under the same reasoning. +pub fn poolSnapshot(pool: *pool_mod.Pool, io: std.Io, out: []pool_mod.Snapshot) usize { + const prev = io.swapCancelProtection(.blocked); + defer _ = io.swapCancelProtection(prev); + return pool.snapshot(io, out) catch |err| switch (err) { + error.Canceled => unreachable, + }; +} + +fn upstreams(pool: *pool_mod.Pool, io: std.Io, arena: Allocator) Allocator.Error![]const UpstreamSample { + var raw: [max_upstreams]pool_mod.Snapshot = undefined; + const count = poolSnapshot(pool, io, &raw); + + const out = try arena.alloc(UpstreamSample, count); + for (raw[0..count], out) |entry, *slot| { + slot.* = .{ + .url = try arena.dupe(u8, entry.url), + .enabled = entry.enabled, + .available = entry.available, + .consecutive_failures = entry.consecutive_failures, + .total_successes = entry.total_successes, + .total_failures = entry.total_failures, + .success_rate = entry.success_rate, + }; + } + return out; +} + +// --------------------------------------------------------------------------- +// rendering +// --------------------------------------------------------------------------- + +pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void { + try gauge(w, "nxdns_up", "1 while the nxdns process is answering scrapes.", 1); + + inline for (dns_stat_fields, sample.dns) |field, value| { + try counter( + w, + "nxdns_dns_" ++ field.name ++ "_total", + "DNS pipeline counter: " ++ field.name ++ ".", + value, + ); + } + + try counterGroup(w, "nxdns_querylog_", "Query log writer counter", sample.logger); + try counterGroup(w, "nxdns_log_", "Diagnostic log sink counter", sample.log_sink); + + if (sample.cache) |cache| { + try counterGroup(w, "nxdns_cache_", "DNS cache counter", cache.stats); + try gauge(w, "nxdns_cache_entries", "Responses currently held in the DNS cache.", cache.entries); + try gauge(w, "nxdns_cache_memory_bytes", "Bytes held by the DNS cache.", cache.memory_bytes); + } + + if (sample.limiter) |limiter| { + try counterGroup(w, "nxdns_dns_rate_limit_", "DNS rate limiter counter", limiter.stats); + try gauge( + w, + "nxdns_dns_rate_limit_tracked_clients", + "Client addresses the DNS rate limiter is tracking.", + limiter.tracked_clients, + ); + } + + if (sample.tracker) |tracker| { + try counterGroup(w, "nxdns_clients_", "Client tracker counter", tracker.stats); + try gauge( + w, + "nxdns_clients_pending", + "Clients seen but not yet written to the database.", + tracker.pending_clients, + ); + } + + if (sample.retention) |retention| { + try counterGroup(w, "nxdns_retention_", "Query log retention counter", retention); + } + + if (sample.blocklist) |blocklist| { + try counter( + w, + "nxdns_blocklist_refreshes_gated_total", + "Scheduled blocklist refreshes skipped because the disk was low.", + blocklist.refreshes_gated, + ); + if (blocklist.generation) |generation| { + try gauge( + w, + "nxdns_blocklist_generation", + "Generation of the filter snapshot currently answering queries.", + generation, + ); + } + } + + if (sample.disk) |disk| { + try gauge(w, "nxdns_disk_free_bytes", "Free bytes on the data filesystem.", disk.gauges.free_bytes); + try gauge(w, "nxdns_disk_db_bytes", "Bytes held by the databases.", disk.gauges.db_bytes); + try gauge(w, "nxdns_disk_log_bytes", "Bytes held by the log files.", disk.gauges.log_bytes); + try counter( + w, + "nxdns_disk_sample_failures_total", + "Disk measurements that failed.", + disk.sample_failures, + ); + } + + if (sample.upstreams.len != 0) try renderUpstreams(w, sample.upstreams); +} + +fn renderUpstreams(w: *std.Io.Writer, list: []const UpstreamSample) std.Io.Writer.Error!void { + try labeledHead(w, "nxdns_upstream_up", "1 while an upstream is enabled and healthy.", "gauge"); + for (list) |entry| try labeledValue(w, "nxdns_upstream_up", entry.url, @intFromBool(entry.available)); + + try labeledHead(w, "nxdns_upstream_enabled", "1 while an upstream is enabled by configuration.", "gauge"); + for (list) |entry| try labeledValue(w, "nxdns_upstream_enabled", entry.url, @intFromBool(entry.enabled)); + + try labeledHead(w, "nxdns_upstream_success_rate", "Share of recent exchanges that succeeded.", "gauge"); + for (list) |entry| { + try w.writeAll("nxdns_upstream_success_rate{url=\""); + try writeLabelValue(w, entry.url); + try w.print("\"}} {d:.4}\n", .{entry.success_rate}); + } + + try labeledHead( + w, + "nxdns_upstream_consecutive_failures", + "Failures since an upstream last answered.", + "gauge", + ); + for (list) |entry| { + try labeledValue(w, "nxdns_upstream_consecutive_failures", entry.url, entry.consecutive_failures); + } + + try labeledHead(w, "nxdns_upstream_successes_total", "Exchanges an upstream answered.", "counter"); + for (list) |entry| try labeledValue(w, "nxdns_upstream_successes_total", entry.url, entry.total_successes); + + try labeledHead(w, "nxdns_upstream_failures_total", "Exchanges an upstream failed.", "counter"); + for (list) |entry| try labeledValue(w, "nxdns_upstream_failures_total", entry.url, entry.total_failures); +} + +/// Every field of a plain counter struct, under one prefix. +fn counterGroup( + w: *std.Io.Writer, + comptime prefix: []const u8, + comptime help: []const u8, + value: anytype, +) std.Io.Writer.Error!void { + inline for (@typeInfo(@TypeOf(value)).@"struct".fields) |field| { + try counter(w, prefix ++ field.name ++ "_total", help ++ ": " ++ field.name ++ ".", @field(value, field.name)); + } +} + +fn counter(w: *std.Io.Writer, name: []const u8, help: []const u8, value: u64) std.Io.Writer.Error!void { + try w.print("# HELP {s} {s}\n# TYPE {s} counter\n{s} {d}\n", .{ name, help, name, name, value }); +} + +fn gauge(w: *std.Io.Writer, name: []const u8, help: []const u8, value: u64) std.Io.Writer.Error!void { + try w.print("# HELP {s} {s}\n# TYPE {s} gauge\n{s} {d}\n", .{ name, help, name, name, value }); +} + +fn labeledHead( + w: *std.Io.Writer, + name: []const u8, + help: []const u8, + kind: []const u8, +) std.Io.Writer.Error!void { + try w.print("# HELP {s} {s}\n# TYPE {s} {s}\n", .{ name, help, name, kind }); +} + +fn labeledValue( + w: *std.Io.Writer, + name: []const u8, + url: []const u8, + value: u64, +) std.Io.Writer.Error!void { + try w.print("{s}{{url=\"", .{name}); + try writeLabelValue(w, url); + try w.print("\"}} {d}\n", .{value}); +} + +/// The three characters the exposition format reserves inside a label value. +fn writeLabelValue(w: *std.Io.Writer, value: []const u8) std.Io.Writer.Error!void { + for (value) |byte| switch (byte) { + '\\' => try w.writeAll("\\\\"), + '"' => try w.writeAll("\\\""), + '\n' => try w.writeAll("\\n"), + else => try w.writeByte(byte), + }; +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const logger_mod = @import("../storage/logger.zig"); +const testing = std.testing; + +/// A handler with no upstream reachable: every test here reads counters and +/// never runs a query. +fn testHandler() dns_handler.Handler { + return .{ + .upstream = .{ .ptr = undefined, .exchangeFn = undefined }, + .blocking = .{ .mode = .zero, .ttl = 5 }, + .forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake }, + }; +} + +fn renderToString(gpa: Allocator, sample: Sample) ![]u8 { + var allocating: std.Io.Writer.Allocating = .init(gpa); + errdefer allocating.deinit(); + try render(&allocating.writer, sample); + return allocating.toOwnedSlice(); +} + +test "a full sample renders the whole exposition, byte for byte" { + var dns: DnsCounters = @splat(0); + dns[0] = 12; + dns[1] = 3; + + const upstream_list = [_]UpstreamSample{ + .{ + .url = "https://dns.example/dns-query", + .enabled = true, + .available = true, + .consecutive_failures = 0, + .total_successes = 9, + .total_failures = 1, + .success_rate = 0.9, + }, + }; + + const sample: Sample = .{ + .dns = dns, + .logger = .{ .queries_dropped = 1, .rows_written = 40, .batches_gated = 2 }, + .log_sink = .{ .lines_written = 5, .lines_deduped = 1, .rotations = 0, .sink_errors = 0 }, + .cache = .{ + .stats = .{ .hits = 7, .misses = 8, .inserts = 6, .evictions = 1, .expirations = 2, .invalid_hits = 0 }, + .entries = 5, + .memory_bytes = 4096, + }, + .limiter = .{ .stats = .{ .allowed = 20, .refused = 2, .untracked = 1 }, .tracked_clients = 3 }, + .tracker = .{ + .stats = .{ .tracked = 4, .flushed = 3, .dropped_full = 0, .pruned = 1, .flush_failures = 0 }, + .pending_clients = 2, + }, + .retention = .{ .passes = 7, .rows_pruned = 100, .checkpoints = 7, .vacuums = 1 }, + .blocklist = .{ .refreshes_gated = 2, .generation = 4 }, + .disk = .{ + .gauges = .{ .free_bytes = 1000, .db_bytes = 200, .log_bytes = 30 }, + .sample_failures = 1, + }, + .upstreams = &upstream_list, + }; + + const text = try renderToString(testing.allocator, sample); + defer testing.allocator.free(text); + + // Every family, in the order `render` writes them. The golden text is the + // contract a scrape reads; a counter that changes name changes this test. + try testing.expectEqualStrings( + \\# HELP nxdns_up 1 while the nxdns process is answering scrapes. + \\# TYPE nxdns_up gauge + \\nxdns_up 1 + \\# HELP nxdns_dns_queries_total DNS pipeline counter: queries. + \\# TYPE nxdns_dns_queries_total counter + \\nxdns_dns_queries_total 12 + \\# HELP nxdns_dns_dropped_malformed_total DNS pipeline counter: dropped_malformed. + \\# TYPE nxdns_dns_dropped_malformed_total counter + \\nxdns_dns_dropped_malformed_total 3 + \\ + , text[0..std.mem.indexOf(u8, text, "# HELP nxdns_dns_formerr_total").?]); + + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_querylog_queries_dropped_total 1\n")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_log_lines_written_total 5\n")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_hits_total 7\n")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_entries 5\n")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_memory_bytes 4096\n")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_rate_limit_refused_total 2\n")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_rate_limit_tracked_clients 3\n")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_clients_dropped_full_total 0\n")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_clients_pending 2\n")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_retention_rows_pruned_total 100\n")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_blocklist_refreshes_gated_total 2\n")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_blocklist_generation 4\n")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_disk_free_bytes 1000\n")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_disk_sample_failures_total 1\n")); + try testing.expect(std.mem.containsAtLeast( + u8, + text, + 1, + "nxdns_upstream_up{url=\"https://dns.example/dns-query\"} 1\n", + )); + try testing.expect(std.mem.containsAtLeast( + u8, + text, + 1, + "nxdns_upstream_success_rate{url=\"https://dns.example/dns-query\"} 0.9000\n", + )); + try testing.expect(std.mem.endsWith( + u8, + text, + "nxdns_upstream_failures_total{url=\"https://dns.example/dns-query\"} 1\n", + )); +} + +test "every HELP line has a TYPE line and a sample, and every sample a name" { + const text = try renderToString(testing.allocator, .{}); + defer testing.allocator.free(text); + + var helps: usize = 0; + var types: usize = 0; + var samples: usize = 0; + var lines = std.mem.splitScalar(u8, text, '\n'); + while (lines.next()) |line| { + if (line.len == 0) continue; + if (std.mem.startsWith(u8, line, "# HELP ")) { + helps += 1; + } else if (std.mem.startsWith(u8, line, "# TYPE ")) { + types += 1; + } else { + samples += 1; + try testing.expect(std.mem.startsWith(u8, line, "nxdns_")); + } + } + try testing.expectEqual(helps, types); + try testing.expectEqual(helps, samples); + // `nxdns_up` plus every DNS counter: the families a bare state still has. + try testing.expectEqual(1 + dns_stat_fields.len + 7, samples); +} + +test "an unwired collaborator omits its family rather than reporting zeros" { + const text = try renderToString(testing.allocator, .{}); + defer testing.allocator.free(text); + + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_up 1\n")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_queries_total 0\n")); + try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_")); + try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_disk_")); + try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_upstream_")); + try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_blocklist_")); +} + +test "a label value escapes the characters the format reserves" { + const upstream_list = [_]UpstreamSample{.{ + .url = "https://dns.example/a\"b\\c", + .enabled = true, + .available = false, + .consecutive_failures = 2, + .total_successes = 0, + .total_failures = 2, + .success_rate = 0, + }}; + const text = try renderToString(testing.allocator, .{ .upstreams = &upstream_list }); + defer testing.allocator.free(text); + + try testing.expect(std.mem.containsAtLeast( + u8, + text, + 1, + "nxdns_upstream_up{url=\"https://dns.example/a\\\"b\\\\c\"} 0\n", + )); +} + +test "collect reads the live counters of the components it is given" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var cache = try dns_cache.DnsCache.init(testing.allocator, .{ .size = 4 }); + defer cache.deinit(); + cache.stats.hits = 11; + cache.stats.misses = 5; + + var limiter = try rate_limiter.RateLimiter.init(testing.allocator, .{ .limit = 10, .window_seconds = 60 }); + defer limiter.deinit(); + limiter.stats.refused = 3; + + var handler = testHandler(); + handler.cache = &cache; + handler.limiter = &limiter; + handler.stats.queries.store(42, .monotonic); + handler.stats.blocked.store(7, .monotonic); + + var queue_buf: [4]logger_mod.Entry = undefined; + var query_logger: logger_mod.Logger = .init(.{}, &queue_buf); + query_logger.rows_written.store(90, .monotonic); + + var tracker: clients.Tracker = .init(30); + var retention: retention_mod.Retention = .init(.{}); + + var state: server.WebState = .{ + .gpa = testing.allocator, + .handler = &handler, + .logger = &query_logger, + .tracker = &tracker, + .retention = &retention, + }; + + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const sample = try collect(&state, io, arena.allocator()); + + try testing.expectEqual(@as(u64, 42), sample.dns[fieldIndex("queries")]); + try testing.expectEqual(@as(u64, 7), sample.dns[fieldIndex("blocked")]); + try testing.expectEqual(@as(u64, 11), sample.cache.?.stats.hits); + try testing.expectEqual(@as(u64, 5), sample.cache.?.stats.misses); + try testing.expectEqual(@as(u64, 0), sample.cache.?.entries); + try testing.expectEqual(@as(u64, 3), sample.limiter.?.stats.refused); + try testing.expectEqual(@as(u64, 90), sample.logger.rows_written); + try testing.expectEqual(@as(u64, 0), sample.tracker.?.pending_clients); + try testing.expectEqual(@as(u64, 0), sample.retention.?.passes); + try testing.expectEqual(@as(?BlocklistSample, null), sample.blocklist); + try testing.expectEqual(@as(usize, 0), sample.upstreams.len); +} + +fn fieldIndex(comptime name: []const u8) usize { + inline for (dns_stat_fields, 0..) |field, i| { + if (comptime std.mem.eql(u8, field.name, name)) return i; + } + @compileError("no such counter: " ++ name); +} diff --git a/src/web/openapi.yaml b/src/web/openapi.yaml new file mode 100644 index 0000000..4a45eaf --- /dev/null +++ b/src/web/openapi.yaml @@ -0,0 +1,2255 @@ +openapi: 3.0.3 +info: + title: nxdns admin API + version: "1.0" + description: | + The REST API of the nxdns DNS sinkhole, served by nxdns itself on + `web.bind:web.port` (plain HTTP; TLS termination is the operator's proxy). + + Conventions, which every endpoint follows: + + - All JSON field names are snake_case, matching settings keys and SQL + column names. + - Every error response carries the envelope `{"error": ""}`. + Internal detail never reaches the wire; a 500 body is generic and the + cause goes to the server log. + - Request bodies are strict: an unknown field is a 400, a body over + 1 MiB is a 413. + - Authentication is enabled iff `web.password_hash` is set. When it is, + every operation marked with `sessionCookie` security answers 401 + without a valid session cookie. When it is not, no operation requires + authentication. Monitoring endpoints, the login, this document and the + static assets are always open. + - Requests spend one token of the per-address bucket + (`web.api_rate_limit_per_min`); over-budget requests answer 429 with a + `Retry-After` header. `/metrics` and `/api/health` are exempt so a + Prometheus scrape can never be throttled; `/api/queries/live` is + exempt because one long-lived stream is bounded by + `web.sse_max_connections_per_ip` instead. + - Mutations to groups, blocklists, rules, local records, forward zones, + clients and client prefixes take effect live. Upstreams and + `/api/settings` are restart-required. + +servers: + - url: / + +security: + - sessionCookie: [] + +paths: + /metrics: + get: + summary: Prometheus metrics + description: | + Prometheus text exposition format 0.0.4, `nxdns_` prefix. Always + unauthenticated and never rate limited. + security: [] + responses: + "200": + description: Metrics exposition. + content: + text/plain: + schema: + type: string + + /api/health: + get: + summary: Health rollup + description: | + Always 200; `status` is `degraded` when the disk is not ok, no + upstream is available, or the query-log writer failed. Always + unauthenticated and never rate limited. + security: [] + responses: + "200": + description: Health snapshot. + content: + application/json: + schema: + $ref: "#/components/schemas/Health" + + /api/version: + get: + summary: Build and uptime + security: [] + responses: + "200": + description: Version information. + content: + application/json: + schema: + $ref: "#/components/schemas/Version" + "429": + $ref: "#/components/responses/RateLimited" + + /api/openapi.yaml: + get: + summary: This document + security: [] + responses: + "200": + description: The OpenAPI contract, verbatim. + content: + application/yaml: + schema: + type: string + "429": + $ref: "#/components/responses/RateLimited" + + /api/auth/login: + post: + summary: Log in + description: | + Verifies the admin password. On success the response sets the + `nxdns_session` cookie (`HttpOnly; SameSite=Lax; Path=/`). When no + password is configured the request succeeds without minting a + session and `auth_required` is false. + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + responses: + "200": + description: Authenticated; the cookie is set iff `auth_required`. + headers: + Set-Cookie: + schema: + type: string + description: "`nxdns_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=`" + content: + application/json: + schema: + $ref: "#/components/schemas/LoginResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + description: Wrong or empty password. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "413": + $ref: "#/components/responses/BodyTooLarge" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/auth/logout: + post: + summary: Log out + description: Deletes the session and clears the cookie. Always succeeds. + responses: + "200": + description: Logged out; the cookie is cleared. + headers: + Set-Cookie: + schema: + type: string + description: "`nxdns_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`" + content: + application/json: + schema: + type: object + required: [authenticated] + properties: + authenticated: + type: boolean + enum: [false] + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + + /api/queries: + get: + summary: Query log page + description: | + Keyset pagination over the query log, newest first. Follow + `next_before` until it is null. + parameters: + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 1000, default: 100 } + - name: before + in: query + description: Return rows with id strictly below this cursor. + schema: { type: integer, minimum: 1 } + - name: domain + in: query + description: Substring match; `%` and `_` are literal. + schema: { type: string, maxLength: 253 } + - name: client + in: query + description: Exact client address. + schema: { type: string, maxLength: 64 } + - name: blocked + in: query + schema: { type: boolean } + - name: since + in: query + description: Unix seconds, inclusive. + schema: { type: integer } + - name: until + in: query + description: Unix seconds, exclusive. + schema: { type: integer } + responses: + "200": + description: One page. + content: + application/json: + schema: + $ref: "#/components/schemas/QueriesPage" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/queries/live: + get: + summary: Live query stream (server-sent events) + description: | + `text/event-stream`. The stream opens with `retry: 3000`, then sends + one `event: query` frame per resolved query whose `data:` line is a + JSON object with the `/api/queries` row fields minus `id` (the entry + precedes persistence). A `: ping` comment goes out every 15 seconds. + A client that falls more than 64 events behind is disconnected and + should re-sync via `/api/queries` after reconnecting. Connections + per address are capped by `web.sse_max_connections_per_ip`; the + stream does not spend rate-limit tokens. + responses: + "200": + description: An event stream; stays open until either side closes. + content: + text/event-stream: + schema: + type: string + "401": + $ref: "#/components/responses/Unauthorized" + "429": + description: This address already holds its maximum number of live streams. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "503": + $ref: "#/components/responses/Unavailable" + + /api/stats: + get: + summary: Totals for a period + parameters: + - $ref: "#/components/parameters/Period" + responses: + "200": + description: Totals over the period's UTC-aligned window. + content: + application/json: + schema: + $ref: "#/components/schemas/StatsTotals" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/stats/timeseries: + get: + summary: Bucketed counts for a period + description: | + Fixed-width UTC buckets covering the same window `/api/stats` + reports for the period: 1h into 60 one-minute buckets, 24h into 48 + half-hour buckets, 7d into 168 one-hour buckets, 30d into 120 + six-hour buckets. Empty buckets are zero-filled. + parameters: + - $ref: "#/components/parameters/Period" + responses: + "200": + description: The bucket series. + content: + application/json: + schema: + $ref: "#/components/schemas/StatsTimeseries" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/lookup: + get: + summary: Explain a domain + description: | + Runs the full pipeline view for a domain without answering DNS: + local records, forward zone, block decision with reason and matched + pattern, source URL when a blocklist matched, and any safe-search + rewrite. + parameters: + - name: domain + in: query + required: true + schema: { type: string, maxLength: 253 } + - name: group_id + in: query + description: Group row id; the default group when absent. + schema: { type: integer, minimum: 1 } + responses: + "200": + description: The pipeline's view of the domain. + content: + application/json: + schema: + $ref: "#/components/schemas/Lookup" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + "503": + $ref: "#/components/responses/Unavailable" + + /api/upstream/health: + get: + summary: Upstream pool health + responses: + "200": + description: Per-upstream state and the availability rollup. + content: + application/json: + schema: + $ref: "#/components/schemas/UpstreamHealth" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + "503": + $ref: "#/components/responses/Unavailable" + + /api/groups: + get: + summary: List groups + responses: + "200": + description: Every group. + content: + application/json: + schema: + type: object + required: [groups] + properties: + groups: + type: array + items: + $ref: "#/components/schemas/Group" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + post: + summary: Create a group + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GroupInput" + responses: + "201": + description: Created; applied live. + content: + application/json: + schema: + $ref: "#/components/schemas/Group" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "409": + $ref: "#/components/responses/Conflict" + "413": + $ref: "#/components/responses/BodyTooLarge" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/groups/{id}: + parameters: + - $ref: "#/components/parameters/RowId" + get: + summary: Read a group + responses: + "200": + description: The group. + content: + application/json: + schema: + $ref: "#/components/schemas/Group" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + put: + summary: Update a group + description: The `default` group cannot be renamed (409). + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GroupInput" + responses: + "200": + description: Updated; applied live. + content: + application/json: + schema: + $ref: "#/components/schemas/Group" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "413": + $ref: "#/components/responses/BodyTooLarge" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + delete: + summary: Delete a group + description: | + The `default` group cannot be deleted, and neither can a group that + still has clients (409 either way). + responses: + "204": + description: Deleted; applied live. + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/groups/{id}/sources: + parameters: + - $ref: "#/components/parameters/RowId" + get: + summary: Blocklist sources assigned to a group + responses: + "200": + description: The assignment. + content: + application/json: + schema: + $ref: "#/components/schemas/GroupSources" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + put: + summary: Replace the assignment + description: Idempotent full-set replace; duplicates collapse. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GroupSources" + responses: + "200": + description: Replaced; applied live. + content: + application/json: + schema: + $ref: "#/components/schemas/GroupSources" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "413": + $ref: "#/components/responses/BodyTooLarge" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/blocklists: + get: + summary: List blocklist sources + responses: + "200": + description: Every source with its load counters. + content: + application/json: + schema: + type: object + required: [blocklists] + properties: + blocklists: + type: array + items: + $ref: "#/components/schemas/Blocklist" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + post: + summary: Add a blocklist source + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BlocklistInput" + responses: + "201": + description: Created; applied live. + content: + application/json: + schema: + $ref: "#/components/schemas/BlocklistEcho" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "409": + $ref: "#/components/responses/Conflict" + "413": + $ref: "#/components/responses/BodyTooLarge" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/blocklists/update: + post: + summary: Refresh every enabled source now + description: | + Runs a fetch-and-compile pass over all enabled sources, reloads the + filter, and reports the per-source status snapshot. + responses: + "202": + description: Refresh ran; the snapshot reflects it. + content: + application/json: + schema: + type: object + required: [sources] + properties: + sources: + type: array + items: + $ref: "#/components/schemas/SourceStatus" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/blocklists/{id}: + parameters: + - $ref: "#/components/parameters/RowId" + get: + summary: Read a blocklist source + responses: + "200": + description: The source with its load counters. + content: + application/json: + schema: + $ref: "#/components/schemas/Blocklist" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + put: + summary: Update a blocklist source + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BlocklistInput" + responses: + "200": + description: Updated; applied live. + content: + application/json: + schema: + $ref: "#/components/schemas/BlocklistEcho" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "413": + $ref: "#/components/responses/BodyTooLarge" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + delete: + summary: Delete a blocklist source + responses: + "204": + description: Deleted; applied live. + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/rules: + get: + summary: List rules + responses: + "200": + description: Every allow/block rule. + content: + application/json: + schema: + type: object + required: [rules] + properties: + rules: + type: array + items: + $ref: "#/components/schemas/Rule" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + post: + summary: Create a rule + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RuleInput" + responses: + "201": + description: Created; applied live. + content: + application/json: + schema: + $ref: "#/components/schemas/RuleEcho" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "409": + $ref: "#/components/responses/Conflict" + "413": + $ref: "#/components/responses/BodyTooLarge" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/rules/{id}: + parameters: + - $ref: "#/components/parameters/RowId" + get: + summary: Read a rule + responses: + "200": + description: The rule. + content: + application/json: + schema: + $ref: "#/components/schemas/Rule" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + put: + summary: Update a rule + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RuleInput" + responses: + "200": + description: Updated; applied live. + content: + application/json: + schema: + $ref: "#/components/schemas/RuleEcho" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "413": + $ref: "#/components/responses/BodyTooLarge" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + delete: + summary: Delete a rule + responses: + "204": + description: Deleted; applied live. + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/local-records: + get: + summary: List local DNS records + responses: + "200": + description: Every local record. + content: + application/json: + schema: + type: object + required: [local_records] + properties: + local_records: + type: array + items: + $ref: "#/components/schemas/LocalRecord" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + post: + summary: Create a local record + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRecordInput" + responses: + "201": + description: Created; applied live. + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRecord" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "409": + $ref: "#/components/responses/Conflict" + "413": + $ref: "#/components/responses/BodyTooLarge" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/local-records/{id}: + parameters: + - $ref: "#/components/parameters/RowId" + get: + summary: Read a local record + responses: + "200": + description: The record. + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRecord" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + put: + summary: Update a local record + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRecordInput" + responses: + "200": + description: Updated; applied live. + content: + application/json: + schema: + $ref: "#/components/schemas/LocalRecord" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "413": + $ref: "#/components/responses/BodyTooLarge" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + delete: + summary: Delete a local record + responses: + "204": + description: Deleted; applied live. + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/forward-zones: + get: + summary: List forward zones + responses: + "200": + description: Every forward zone. + content: + application/json: + schema: + type: object + required: [forward_zones] + properties: + forward_zones: + type: array + items: + $ref: "#/components/schemas/ForwardZone" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + post: + summary: Create a forward zone + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ForwardZoneInput" + responses: + "201": + description: Created; applied live. + content: + application/json: + schema: + $ref: "#/components/schemas/ForwardZone" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "409": + $ref: "#/components/responses/Conflict" + "413": + $ref: "#/components/responses/BodyTooLarge" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/forward-zones/{id}: + parameters: + - $ref: "#/components/parameters/RowId" + get: + summary: Read a forward zone + responses: + "200": + description: The zone. + content: + application/json: + schema: + $ref: "#/components/schemas/ForwardZone" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + put: + summary: Update a forward zone + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ForwardZoneInput" + responses: + "200": + description: Updated; applied live. + content: + application/json: + schema: + $ref: "#/components/schemas/ForwardZone" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "413": + $ref: "#/components/responses/BodyTooLarge" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + delete: + summary: Delete a forward zone + responses: + "204": + description: Deleted; applied live. + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/clients: + get: + summary: List clients + description: | + Every known client, materialized ones included, each with + `hand_edited`. Clients have no POST: rows appear through DNS + activity or configuration import. + responses: + "200": + description: Every client. + content: + application/json: + schema: + type: object + required: [clients] + properties: + clients: + type: array + items: + $ref: "#/components/schemas/Client" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/clients/{id}: + parameters: + - $ref: "#/components/parameters/RowId" + get: + summary: Read a client + responses: + "200": + description: The client. + content: + application/json: + schema: + $ref: "#/components/schemas/Client" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + put: + summary: Rename or regroup a client + description: | + Sets `hand_edited`. The address is the client's identity and cannot + be changed. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ClientEdit" + responses: + "200": + description: The updated row; applied live. + content: + application/json: + schema: + $ref: "#/components/schemas/Client" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "413": + $ref: "#/components/responses/BodyTooLarge" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + delete: + summary: Forget a client + description: A live client re-materializes on its next query. + responses: + "204": + description: Deleted; applied live. + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/client-prefixes: + get: + summary: List client prefixes + responses: + "200": + description: The whole prefix table. + content: + application/json: + schema: + type: object + required: [client_prefixes] + properties: + client_prefixes: + type: array + items: + $ref: "#/components/schemas/ClientPrefix" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + put: + summary: Replace the prefix table + description: | + Atomic whole-list replace; an empty array clears the table. A prefix + listed twice is a 409. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [client_prefixes] + properties: + client_prefixes: + type: array + items: + $ref: "#/components/schemas/ClientPrefixInput" + responses: + "200": + description: The stored table, re-read; applied live. + content: + application/json: + schema: + type: object + required: [client_prefixes] + properties: + client_prefixes: + type: array + items: + $ref: "#/components/schemas/ClientPrefix" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "409": + $ref: "#/components/responses/Conflict" + "413": + $ref: "#/components/responses/BodyTooLarge" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/upstreams: + get: + summary: List upstream resolvers + responses: + "200": + description: Every configured upstream. + content: + application/json: + schema: + type: object + required: [upstreams] + properties: + upstreams: + type: array + items: + $ref: "#/components/schemas/Upstream" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + post: + summary: Add an upstream + description: Restart-required; the running pool is not changed. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpstreamInput" + responses: + "201": + description: Created; takes effect on restart. + content: + application/json: + schema: + $ref: "#/components/schemas/UpstreamEcho" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "409": + $ref: "#/components/responses/Conflict" + "413": + $ref: "#/components/responses/BodyTooLarge" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/upstreams/{id}: + parameters: + - $ref: "#/components/parameters/RowId" + get: + summary: Read an upstream + responses: + "200": + description: The upstream. + content: + application/json: + schema: + $ref: "#/components/schemas/Upstream" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + put: + summary: Update an upstream + description: Restart-required; the running pool is not changed. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpstreamInput" + responses: + "200": + description: Updated; takes effect on restart. + content: + application/json: + schema: + $ref: "#/components/schemas/UpstreamEcho" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "413": + $ref: "#/components/responses/BodyTooLarge" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + delete: + summary: Delete an upstream + description: The last enabled upstream cannot be removed (409). + responses: + "204": + description: Deleted; takes effect on restart. + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + + /api/pause: + get: + summary: Read the pause state + responses: + "200": + description: | + `until` is null while unpaused and while paused indefinitely; + `paused` disambiguates. + content: + application/json: + schema: + $ref: "#/components/schemas/Pause" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + "503": + $ref: "#/components/responses/Unavailable" + post: + summary: Pause or resume blocking + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PauseRequest" + responses: + "200": + description: The state after applying. + content: + application/json: + schema: + $ref: "#/components/schemas/Pause" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "413": + $ref: "#/components/responses/BodyTooLarge" + "429": + $ref: "#/components/responses/RateLimited" + "503": + $ref: "#/components/responses/Unavailable" + + /api/settings: + get: + summary: Read the scalar settings + description: | + Every stored settings key, plus the derived `web.auth_enabled`. + `restart_required` lists every key, because all scalar settings are + restart-required this milestone; live behavior comes from the + resource endpoints and `/api/pause`. Passwords and hashes are never + serialized. + responses: + "200": + description: The settings and the restart-required key list. + content: + application/json: + schema: + $ref: "#/components/schemas/SettingsEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + put: + summary: Update settings + description: | + Partial update: omitted sections and fields keep their stored + values. The merged configuration is validated before anything is + written; on any failure nothing changes. A non-empty `web.password` + is hashed with argon2id and stored as the hash; changing it clears + every session. `web.password_hash` itself is not accepted. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SettingsPatch" + responses: + "200": + description: The merged settings, as `GET` would report them. + content: + application/json: + schema: + $ref: "#/components/schemas/SettingsEnvelope" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "413": + $ref: "#/components/responses/BodyTooLarge" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/Internal" + "503": + $ref: "#/components/responses/Unavailable" + +components: + securitySchemes: + sessionCookie: + type: apiKey + in: cookie + name: nxdns_session + description: | + Minted by `POST /api/auth/login`. Only meaningful when + `web.password_hash` is configured; without it, every endpoint is + open and the cookie is ignored. + + parameters: + RowId: + name: id + in: path + required: true + description: Positive integer row id. + schema: + type: integer + minimum: 1 + Period: + name: period + in: query + description: Statistics window; the default is `24h`. + schema: + type: string + enum: [1h, 24h, 7d, 30d] + default: 24h + + responses: + BadRequest: + description: Malformed path, query parameter or body; validation failure. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Unauthorized: + description: | + Authentication is enabled and the request carried no valid session + cookie. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + NotFound: + description: No row has this id. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Conflict: + description: | + The change contradicts stored data: a duplicate key, a reference to + a missing row, or a delete the data still depends on. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + BodyTooLarge: + description: The request body exceeds 1 MiB. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + RateLimited: + description: | + The per-address token bucket is empty. `Retry-After` says when one + token will be available. + headers: + Retry-After: + schema: + type: integer + minimum: 1 + description: Seconds until a retry can succeed. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Internal: + description: | + Something on the server failed; the detail is in the server log, + never on the wire. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Unavailable: + description: | + A collaborator this endpoint needs is not running: no configuration + database, no query log, no snapshot loaded, or the server is + shutting down. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + schemas: + Error: + type: object + required: [error] + properties: + error: + type: string + description: Operator-facing message. + + Health: + type: object + required: [status, disk, upstreams, queries_dropped, writer_failed, refreshes_gated, snapshot_generation] + properties: + status: + type: string + enum: [ok, degraded] + disk: + type: object + required: [state, free_bytes, db_bytes, log_bytes, sample_failures] + properties: + state: + type: string + enum: [ok, warn, critical] + free_bytes: { type: integer } + db_bytes: { type: integer } + log_bytes: { type: integer } + sample_failures: { type: integer } + upstreams: + type: object + required: [available, total] + properties: + available: { type: integer } + total: { type: integer } + queries_dropped: { type: integer } + writer_failed: { type: boolean } + refreshes_gated: { type: integer } + snapshot_generation: + type: integer + nullable: true + description: Null until the first filter snapshot is published. + + Version: + type: object + required: [version, git_commit, zig_version, uptime_seconds] + properties: + version: { type: string } + git_commit: { type: string } + zig_version: { type: string } + uptime_seconds: { type: integer } + + LoginRequest: + type: object + required: [password] + properties: + password: { type: string } + + LoginResponse: + type: object + required: [authenticated, auth_required] + properties: + authenticated: + type: boolean + enum: [true] + auth_required: + type: boolean + description: False when no password is configured; no cookie is set. + + QueryRow: + type: object + required: [id, ts, domain, client_ip, qtype, blocked, block_reason, response_time_us, cache_hit, upstream] + properties: + id: { type: integer } + ts: + type: integer + description: Unix seconds. + domain: { type: string } + client_ip: { type: string } + qtype: + type: integer + nullable: true + blocked: { type: boolean } + block_reason: + type: string + description: Empty when the query was not blocked. + response_time_us: + type: integer + nullable: true + cache_hit: + type: boolean + nullable: true + upstream: + type: string + description: Empty for cache hits and local answers. + + QueriesPage: + type: object + required: [queries, next_before] + properties: + queries: + type: array + items: + $ref: "#/components/schemas/QueryRow" + next_before: + type: integer + nullable: true + description: Cursor for the next page; null on the last page. + + StatsTotals: + type: object + required: [period, since, until, queries, blocked, cached, clients, avg_response_time_us] + properties: + period: + type: string + enum: [1h, 24h, 7d, 30d] + since: + type: integer + description: Window start, unix seconds, inclusive. + until: + type: integer + description: Window end, unix seconds, exclusive. + queries: { type: integer } + blocked: { type: integer } + cached: { type: integer } + clients: + type: integer + description: Distinct client addresses in the window. + avg_response_time_us: + type: integer + nullable: true + description: Null when no query in the window recorded a time. + + Bucket: + type: object + required: [ts, queries, blocked, cached] + properties: + ts: + type: integer + description: Bucket start, unix seconds. + queries: { type: integer } + blocked: { type: integer } + cached: { type: integer } + + StatsTimeseries: + type: object + required: [period, since, until, bucket_seconds, buckets] + properties: + period: + type: string + enum: [1h, 24h, 7d, 30d] + since: { type: integer } + until: { type: integer } + bucket_seconds: { type: integer } + buckets: + type: array + items: + $ref: "#/components/schemas/Bucket" + + Lookup: + type: object + required: [domain, group_id, local_records, forward_zone, blocked, reason, matched, source_url, safe_search_rewrite] + properties: + domain: + type: string + description: The normalized form of the queried domain. + group_id: { type: integer } + local_records: + type: boolean + description: Whether a local record answers this name. + forward_zone: + type: string + nullable: true + blocked: { type: boolean } + reason: + type: string + description: The pipeline's decision, e.g. `none` or `blocklist_domain`. + matched: + type: string + description: The matched pattern; empty when nothing matched. + source_url: + type: string + nullable: true + safe_search_rewrite: + type: string + nullable: true + + UpstreamHealth: + type: object + required: [upstreams, available, total] + properties: + upstreams: + type: array + items: + type: object + required: [url, enabled, available, consecutive_failures, total_successes, total_failures, success_rate, last_error] + properties: + url: { type: string } + enabled: { type: boolean } + available: { type: boolean } + consecutive_failures: { type: integer } + total_successes: { type: integer } + total_failures: { type: integer } + success_rate: { type: number } + last_error: + type: string + description: Empty when the upstream never failed. + available: { type: integer } + total: { type: integer } + + Group: + type: object + required: [id, name, safe_search] + properties: + id: { type: integer } + name: { type: string } + safe_search: { type: boolean } + + GroupInput: + type: object + required: [name] + properties: + name: { type: string } + safe_search: + type: boolean + default: false + + GroupSources: + type: object + required: [source_ids] + properties: + source_ids: + type: array + items: { type: integer } + + Blocklist: + type: object + required: [id, url, name, enabled, is_suggested, last_updated, domain_count, wildcard_count, skipped_regex_count, checksum] + properties: + id: { type: integer } + url: { type: string } + name: { type: string } + enabled: { type: boolean } + is_suggested: { type: boolean } + last_updated: + type: integer + nullable: true + domain_count: { type: integer } + wildcard_count: { type: integer } + skipped_regex_count: { type: integer } + checksum: + type: string + nullable: true + + BlocklistInput: + type: object + required: [url, name] + properties: + url: { type: string } + name: { type: string } + enabled: + type: boolean + default: true + is_suggested: + type: boolean + default: false + + BlocklistEcho: + type: object + required: [id, url, name, enabled, is_suggested] + properties: + id: { type: integer } + url: { type: string } + name: { type: string } + enabled: { type: boolean } + is_suggested: { type: boolean } + + SourceStatus: + type: object + required: [id, state, loaded, last_attempt, last_success, url, last_error, domains, wildcards, skipped_regex] + properties: + id: { type: integer } + state: + type: string + description: The refresh outcome, e.g. `loaded` or `fetch_failed`. + loaded: { type: boolean } + last_attempt: { type: integer } + last_success: { type: integer } + url: { type: string } + last_error: + type: string + description: Empty when the last attempt succeeded. + domains: { type: integer } + wildcards: { type: integer } + skipped_regex: { type: integer } + + Rule: + type: object + required: [id, group_id, group, pattern, kind, action, created_at] + properties: + id: { type: integer } + group_id: { type: integer } + group: { type: string } + pattern: { type: string } + kind: + type: string + enum: [exact, wildcard] + action: + type: string + enum: [allow, block] + created_at: { type: integer } + + RuleInput: + type: object + required: [group_id, pattern, kind, action] + properties: + group_id: { type: integer } + pattern: { type: string } + kind: + type: string + enum: [exact, wildcard] + action: + type: string + enum: [allow, block] + + RuleEcho: + type: object + required: [id, group_id, pattern, kind, action] + properties: + id: { type: integer } + group_id: { type: integer } + pattern: { type: string } + kind: + type: string + enum: [exact, wildcard] + action: + type: string + enum: [allow, block] + + LocalRecord: + type: object + required: [id, name, rtype, value, ttl] + properties: + id: { type: integer } + name: { type: string } + rtype: + type: string + enum: [A, AAAA, CNAME] + value: { type: string } + ttl: { type: integer } + + LocalRecordInput: + type: object + required: [name, rtype, value] + properties: + name: { type: string } + rtype: + type: string + enum: [A, AAAA, CNAME] + value: { type: string } + ttl: + type: integer + default: 300 + + ForwardZone: + type: object + required: [id, zone, resolver] + properties: + id: { type: integer } + zone: { type: string } + resolver: { type: string } + + ForwardZoneInput: + type: object + required: [zone, resolver] + properties: + zone: { type: string } + resolver: { type: string } + + Client: + type: object + required: [id, ip, name, group_id, group, hand_edited, first_seen, last_seen] + properties: + id: { type: integer } + ip: { type: string } + name: + type: string + description: Empty when the client was never named. + group_id: { type: integer } + group: { type: string } + hand_edited: { type: boolean } + first_seen: { type: integer } + last_seen: { type: integer } + + ClientEdit: + type: object + required: [group_id] + properties: + name: + type: string + default: "" + group_id: { type: integer } + + ClientPrefix: + type: object + required: [id, prefix, group_id, group, priority] + properties: + id: { type: integer } + prefix: { type: string } + group_id: { type: integer } + group: { type: string } + priority: { type: integer } + + ClientPrefixInput: + type: object + required: [prefix, group_id] + properties: + prefix: { type: string } + group_id: { type: integer } + priority: + type: integer + default: 100 + + Upstream: + type: object + required: [id, url, priority, enabled, tls_name] + properties: + id: { type: integer } + url: { type: string } + priority: { type: integer } + enabled: { type: boolean } + tls_name: + type: string + description: Only meaningful for DoT upstreams; empty otherwise. + + UpstreamInput: + type: object + required: [url] + properties: + url: { type: string } + priority: + type: integer + default: 100 + enabled: + type: boolean + default: true + tls_name: + type: string + default: "" + + UpstreamEcho: + type: object + required: [id, url, priority, enabled, tls_name, restart_required] + properties: + id: { type: integer } + url: { type: string } + priority: { type: integer } + enabled: { type: boolean } + tls_name: { type: string } + restart_required: + type: boolean + enum: [true] + + Pause: + type: object + required: [paused, until] + properties: + paused: { type: boolean } + until: + type: integer + nullable: true + description: | + Unix second filtering resumes; null while unpaused and while + paused indefinitely. + + PauseRequest: + type: object + required: [paused] + properties: + paused: { type: boolean } + duration_seconds: + type: integer + nullable: true + minimum: 1 + maximum: 604800 + description: Only meaningful with `paused = true`; absent means indefinite. + + Settings: + type: object + required: [runtime, upstream, dns, blocking, cache, web, doh_server, dot_server, edns, logging, disk, blocklist_update] + properties: + runtime: + type: object + required: [io_backend] + properties: + io_backend: + type: string + enum: [threaded, evented] + upstream: + type: object + required: [connect_timeout_ms, read_timeout_ms, total_timeout_ms] + properties: + connect_timeout_ms: { type: integer } + read_timeout_ms: { type: integer } + total_timeout_ms: { type: integer } + dns: + type: object + required: [bind_ipv4, bind_ipv6, port, rate_limit, rate_window_seconds] + properties: + bind_ipv4: { type: string } + bind_ipv6: { type: string } + port: { type: integer } + rate_limit: { type: integer } + rate_window_seconds: { type: integer } + blocking: + type: object + required: [response, ttl] + properties: + response: + type: string + enum: [zero, nxdomain] + ttl: { type: integer } + cache: + type: object + required: [size, negative_ttl_max] + properties: + size: { type: integer } + negative_ttl_max: { type: integer } + web: + type: object + required: [enabled, bind, port, session_ttl_hours, api_rate_limit_per_min, api_localhost_exempt, sse_max_connections_per_ip, auth_enabled] + properties: + enabled: { type: boolean } + bind: { type: string } + port: { type: integer } + session_ttl_hours: { type: integer } + api_rate_limit_per_min: { type: integer } + api_localhost_exempt: { type: boolean } + sse_max_connections_per_ip: { type: integer } + auth_enabled: + type: boolean + description: Derived, read-only; true iff a password hash is stored. + doh_server: + $ref: "#/components/schemas/TlsListenerSettings" + dot_server: + $ref: "#/components/schemas/TlsListenerSettings" + edns: + type: object + required: [ecs_mode] + properties: + ecs_mode: + type: string + enum: [strip, forward] + logging: + type: object + required: [level, retention_days, query_log_buffer_max, hide_domains, hide_client_ips, output, file_path, max_size_mb, max_files] + properties: + level: + type: string + enum: [error, warn, info, debug] + retention_days: { type: integer } + query_log_buffer_max: { type: integer } + hide_domains: { type: boolean } + hide_client_ips: { type: boolean } + output: + type: string + enum: [stderr, syslog, file] + file_path: { type: string } + max_size_mb: { type: integer } + max_files: { type: integer } + disk: + type: object + required: [min_free_mb, warn_free_mb] + properties: + min_free_mb: { type: integer } + warn_free_mb: { type: integer } + blocklist_update: + type: object + required: [enabled, interval_hours] + properties: + enabled: { type: boolean } + interval_hours: { type: integer } + + TlsListenerSettings: + type: object + required: [enabled, bind, port, cert_path, key_path] + properties: + enabled: { type: boolean } + bind: { type: string } + port: { type: integer } + cert_path: { type: string } + key_path: { type: string } + + SettingsEnvelope: + type: object + required: [settings, restart_required] + properties: + settings: + $ref: "#/components/schemas/Settings" + restart_required: + type: array + items: { type: string } + description: | + Every `section.field` key that needs a restart to take effect — + currently all of them. + + SettingsPatch: + type: object + description: | + Partial update; every section and every field is optional. The + shape is `Settings` without the derived `web.auth_enabled`, plus + the write-only `web.password`. `web.password_hash` is rejected as + an unknown field. + properties: + runtime: + type: object + properties: + io_backend: { type: string } + upstream: + type: object + properties: + connect_timeout_ms: { type: integer } + read_timeout_ms: { type: integer } + total_timeout_ms: { type: integer } + dns: + type: object + properties: + bind_ipv4: { type: string } + bind_ipv6: { type: string } + port: { type: integer } + rate_limit: { type: integer } + rate_window_seconds: { type: integer } + blocking: + type: object + properties: + response: { type: string } + ttl: { type: integer } + cache: + type: object + properties: + size: { type: integer } + negative_ttl_max: { type: integer } + web: + type: object + properties: + enabled: { type: boolean } + bind: { type: string } + port: { type: integer } + password: + type: string + description: | + Write-only. Hashed with argon2id and stored as the hash; + never echoed. An empty string is ignored, not a removal. + session_ttl_hours: { type: integer } + api_rate_limit_per_min: { type: integer } + api_localhost_exempt: { type: boolean } + sse_max_connections_per_ip: { type: integer } + doh_server: + $ref: "#/components/schemas/TlsListenerPatch" + dot_server: + $ref: "#/components/schemas/TlsListenerPatch" + edns: + type: object + properties: + ecs_mode: { type: string } + logging: + type: object + properties: + level: { type: string } + retention_days: { type: integer } + query_log_buffer_max: { type: integer } + hide_domains: { type: boolean } + hide_client_ips: { type: boolean } + output: { type: string } + file_path: { type: string } + max_size_mb: { type: integer } + max_files: { type: integer } + disk: + type: object + properties: + min_free_mb: { type: integer } + warn_free_mb: { type: integer } + blocklist_update: + type: object + properties: + enabled: { type: boolean } + interval_hours: { type: integer } + + TlsListenerPatch: + type: object + properties: + enabled: { type: boolean } + bind: { type: string } + port: { type: integer } + cert_path: { type: string } + key_path: { type: string } diff --git a/src/web/openapi.zig b/src/web/openapi.zig new file mode 100644 index 0000000..7398569 --- /dev/null +++ b/src/web/openapi.zig @@ -0,0 +1,66 @@ +//! `GET /api/openapi.yaml` — the API contract, served verbatim (ruling 23). +//! +//! The document is hand-written and embedded; nothing renders or validates it +//! at runtime (rendering is Phase 10, external validators are dependencies we +//! refused). What keeps it honest is W10's contract suite plus the tests +//! below: every route the router serves must appear textually in the +//! document, so a route added without documentation fails the build's tests +//! rather than drifting silently. + +const std = @import("std"); + +const http_util = @import("http_util.zig"); +const router = @import("router.zig"); +const server = @import("server.zig"); + +pub const yaml: []const u8 = @embedFile("openapi.yaml"); + +pub const content_type = "application/yaml"; + +pub fn handle( + state: *server.WebState, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void { + _ = state; + _ = io; + return http_util.respondBytes(request, .ok, yaml, content_type, &.{}); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "every served route appears textually in the document" { + for (router.routes) |route| { + var key_buf: [128]u8 = undefined; + // Path keys are two-space indented under `paths:`; requiring the + // colon keeps `/api/groups` from being satisfied by its `{id}` twin. + const key = try std.fmt.bufPrint(&key_buf, "\n {s}:\n", .{route.pattern}); + try testing.expect(std.mem.containsAtLeast(u8, yaml, 1, key)); + + var method_buf: [16]u8 = undefined; + const method = try std.fmt.bufPrint(&method_buf, " {s}:\n", .{@tagName(route.method)}); + _ = std.ascii.lowerString(&method_buf, method); + try testing.expect(std.mem.containsAtLeast(u8, yaml, 1, method_buf[0..method.len])); + } +} + +test "the document does not promise what phase 9 owns" { + // Ruling 2: certs/reload lands with the DoH/DoT server, whole. + try testing.expect(!std.mem.containsAtLeast(u8, yaml, 1, "certs/reload")); +} + +test "the document names the contract's fixed points" { + for ([_][]const u8{ + "openapi: 3.0.3", + "nxdns_session", + "text/event-stream", + "snake_case", + "Retry-After", + }) |needle| { + try testing.expect(std.mem.containsAtLeast(u8, yaml, 1, needle)); + } +} diff --git a/src/web/router.zig b/src/web/router.zig new file mode 100644 index 0000000..407c9c7 --- /dev/null +++ b/src/web/router.zig @@ -0,0 +1,269 @@ +//! Route matching and dispatch. +//! +//! The table is a flat array of literal patterns with at most one `{id}` +//! capture, matched segment by segment. A LAN admin API has a few dozen routes +//! and one request per user action, so a linear scan is the whole algorithm — +//! a trie would buy nothing and cost a build step. +//! +//! Dispatch is where the cross-cutting policies live, in the order a request +//! meets them: match, rate limit, authenticate, handle. Matching comes first +//! because both the limiter exemption (ruling 19: `/metrics` and `/api/health` +//! must never see a 429) and the auth exemption (ruling 18) are properties of +//! the matched route, not of the raw path. + +const std = @import("std"); +const http = std.http; + +const http_util = @import("http_util.zig"); +const routes_table = @import("routes.zig"); +const server = @import("server.zig"); + +/// Every route the server serves. Ruling 23 reads this to prove the OpenAPI +/// document and the contract test cover the whole surface. +pub const routes: []const RouteInfo = routes_table.table; + +pub const HandlerFn = *const fn ( + state: *server.WebState, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void; + +/// Whether a route needs a session cookie when authentication is enabled. +/// Ruling 18 lists the open ones: health, version, metrics, the OpenAPI +/// document, login, and the static assets. +pub const Auth = enum { open, session }; + +/// Whether a route spends an API rate-limit token. Ruling 19 exempts the two +/// monitoring endpoints so a Prometheus scrape can never be throttled. +pub const RateLimit = enum { counted, exempt }; + +pub const RouteInfo = struct { + method: http.Method, + /// Segments separated by `/`, with at most one `{id}` capture, which must + /// be a positive integer row id. + pattern: []const u8, + auth: Auth, + handler: HandlerFn, + rate_limit: RateLimit = .counted, +}; + +pub const Match = union(enum) { + found: Found, + /// The path matches a route registered under a different method. + method_not_allowed, + not_found, + + pub const Found = struct { + route: *const RouteInfo, + id: ?i64, + }; +}; + +/// Matches `segments` (already decoded) against `table`. +pub fn match( + table: []const RouteInfo, + method: http.Method, + segments: []const []const u8, +) Match { + var path_exists = false; + for (table) |*route| { + const id = matchPattern(route.pattern, segments) orelse continue; + if (route.method != method) { + path_exists = true; + continue; + } + return .{ .found = .{ .route = route, .id = id } }; + } + return if (path_exists) .method_not_allowed else .not_found; +} + +/// Returns the `{id}` capture, or a null capture for a pattern without one. +/// The outer optional is "did the pattern match at all". +fn matchPattern(pattern: []const u8, segments: []const []const u8) ??i64 { + var id: ?i64 = null; + var index: usize = 0; + var rest = pattern; + while (rest.len != 0) { + const end = std.mem.findScalar(u8, rest, '/') orelse rest.len; + const part = rest[0..end]; + rest = if (end == rest.len) rest[end..] else rest[end + 1 ..]; + if (part.len == 0) continue; + if (index == segments.len) return null; + const segment = segments[index]; + index += 1; + if (std.mem.eql(u8, part, "{id}")) { + id = std.fmt.parseInt(i64, segment, 10) catch return null; + // A row id is a positive integer; `-1` must 404, not reach SQL. + if (id.? <= 0) return null; + continue; + } + if (!std.mem.eql(u8, part, segment)) return null; + } + if (index != segments.len) return null; + return id; +} + +/// Fills `buf` with the `Allow` header value for a path that matched under +/// other methods. The returned slice borrows `buf`. +pub fn formatAllow(table: []const RouteInfo, segments: []const []const u8, buf: []u8) []const u8 { + var writer: std.Io.Writer = .fixed(buf); + var first = true; + for (table) |*route| { + if (matchPattern(route.pattern, segments) == null) continue; + if (!first) writer.writeAll(", ") catch break; + writer.writeAll(@tagName(route.method)) catch break; + first = false; + } + return writer.buffered(); +} + +/// Runs one request to completion: match, limit, authenticate, handle. +/// +/// Every exit responds. A `WriteFailed` on the way out is the client +/// disconnecting (ruling 28) and ends the connection. +pub fn dispatch( + state: *server.WebState, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void { + const segments = request.path.segments(); + const found = switch (match(state.routes, request.method, segments)) { + .found => |f| f, + .method_not_allowed => { + var buf: [64]u8 = undefined; + const allow = formatAllow(state.routes, segments, &buf); + return respondMethodNotAllowed(request, allow); + }, + // Ruling 24: an unknown non-`/api` path is the SPA's, and the static + // handler answers it with index.html so client-side routing works. An + // unknown `/api` path is a real 404 and must stay JSON. + .not_found => { + if (state.fallback) |fallback| { + if (!std.mem.eql(u8, request.firstSegment(), "api")) { + return fallback(state, io, request); + } + } + return http_util.respondError(request, .not_found, "not found"); + }, + }; + + request.id = found.id; + + if (found.route.rate_limit == .counted) { + const verdict = state.check_limit(state, io, request); + if (!verdict.allowed) return respondRateLimited(request, verdict.retry_after_s); + } + + if (found.route.auth == .session and !state.check_auth(state, io, request)) { + return http_util.respondError(request, .unauthorized, "authentication required"); + } + + return found.route.handler(state, io, request); +} + +fn respondMethodNotAllowed(request: *http_util.Request, allow: []const u8) http_util.HandlerError!void { + return http_util.respondBytes( + request, + .method_not_allowed, + "{\"error\":\"method not allowed\"}", + http_util.content_type_json, + &.{.{ .name = "allow", .value = allow }}, + ); +} + +fn respondRateLimited(request: *http_util.Request, retry_after_seconds: u32) http_util.HandlerError!void { + var buf: [16]u8 = undefined; + const retry_after = std.fmt.bufPrint(&buf, "{d}", .{retry_after_seconds}) catch "60"; + return http_util.respondBytes( + request, + .too_many_requests, + "{\"error\":\"rate limited\"}", + http_util.content_type_json, + &.{.{ .name = "retry-after", .value = retry_after }}, + ); +} + +const testing = std.testing; + +fn noopHandler( + state: *server.WebState, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void { + _ = state; + _ = io; + _ = request; +} + +const test_table = [_]RouteInfo{ + .{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = noopHandler, .rate_limit = .exempt }, + .{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = noopHandler }, + .{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = noopHandler }, + .{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler }, + .{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler }, + .{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler }, + .{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = noopHandler }, +}; + +fn matchPath(method: http.Method, path: []const u8) Match { + var buf: [128]u8 = undefined; + @memcpy(buf[0..path.len], path); + const parsed = http_util.parsePath(buf[0..path.len]) catch return .not_found; + return match(&test_table, method, parsed.segments()); +} + +test "the matching table resolves every registered shape" { + const cases = [_]struct { method: http.Method, path: []const u8, id: ?i64 }{ + .{ .method = .GET, .path = "/api/health", .id = null }, + .{ .method = .GET, .path = "/api/groups", .id = null }, + .{ .method = .POST, .path = "/api/groups", .id = null }, + .{ .method = .GET, .path = "/api/groups/7", .id = 7 }, + .{ .method = .PUT, .path = "/api/groups/7", .id = 7 }, + .{ .method = .DELETE, .path = "/api/groups/12", .id = 12 }, + .{ .method = .PUT, .path = "/api/groups/12/sources", .id = 12 }, + }; + for (cases) |case| { + const found = matchPath(case.method, case.path).found; + try testing.expectEqual(case.id, found.id); + try testing.expectEqual(case.method, found.route.method); + } +} + +test "a trailing slash matches the same route" { + try testing.expectEqual(@as(?i64, 7), matchPath(.GET, "/api/groups/7/").found.id); + try testing.expectEqual(@as(?i64, null), matchPath(.GET, "/api/groups/").found.id); +} + +test "an unregistered path is not found" { + try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/nope"))); + try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api"))); + try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/groups/7/sources/1"))); +} + +test "a non-numeric or non-positive id does not match the capture" { + try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/groups/abc"))); + try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/groups/0"))); + try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/groups/-1"))); +} + +test "a known path under an unknown method is 405, not 404" { + try testing.expectEqual(.method_not_allowed, std.meta.activeTag(matchPath(.DELETE, "/api/groups"))); + try testing.expectEqual(.method_not_allowed, std.meta.activeTag(matchPath(.POST, "/api/groups/7"))); + try testing.expectEqual(.method_not_allowed, std.meta.activeTag(matchPath(.PUT, "/api/health"))); +} + +test "the allow header lists every method the path accepts" { + var path_buf = "/api/groups".*; + const collection = try http_util.parsePath(&path_buf); + var buf: [64]u8 = undefined; + try testing.expectEqualStrings("GET, POST", formatAllow(&test_table, collection.segments(), &buf)); + + var item_buf = "/api/groups/7".*; + const item = try http_util.parsePath(&item_buf); + try testing.expectEqualStrings("GET, PUT, DELETE", formatAllow(&test_table, item.segments(), &buf)); +} + +test "the shipped route table is the one the router matches against" { + try testing.expectEqual(routes_table.table.ptr, routes.ptr); + try testing.expectEqual(routes_table.table.len, routes.len); +} diff --git a/src/web/routes.zig b/src/web/routes.zig new file mode 100644 index 0000000..6ab5175 --- /dev/null +++ b/src/web/routes.zig @@ -0,0 +1,194 @@ +//! The route table. +//! +//! Deliberately its own file: `router.zig` owns matching and dispatch, and the +//! entries are filled in by the session that writes the handlers (milestone 8, +//! session W8). Ruling 23's drift guards read `router.routes`, which is this +//! array re-exported, so the contract test and the router can never disagree +//! about what the server serves. +//! +//! Adding a route means adding one entry here — and documenting it in +//! openapi.yaml, which openapi.zig's tests and W10's drift guards enforce. +//! Nothing else in the web layer knows the path set. +//! +//! Policy columns restate two rulings as data: `auth = .open` is exactly +//! ruling 18's exemption list (monitoring endpoints, the contract, the login +//! itself), and `rate_limit = .exempt` is ruling 19's (Prometheus must never +//! see 429) plus the live stream, which holds one request across its whole +//! life and is bounded by the SSE per-address cap instead of the token +//! bucket. The static assets are ruling 18's remaining exemption; they are +//! not routes — the router sends unmatched non-`/api` paths to +//! `WebState.fallback` before any policy check. + +const router = @import("router.zig"); + +const auth = @import("handlers/auth.zig"); +const blocklists = @import("handlers/blocklists.zig"); +const clients = @import("handlers/clients.zig"); +const groups = @import("handlers/groups.zig"); +const health = @import("handlers/health.zig"); +const live = @import("handlers/live.zig"); +const local = @import("handlers/local.zig"); +const lookup = @import("handlers/lookup.zig"); +const metrics = @import("metrics.zig"); +const openapi = @import("openapi.zig"); +const pause = @import("handlers/pause.zig"); +const queries = @import("handlers/queries.zig"); +const rules = @import("handlers/rules.zig"); +const settings = @import("handlers/settings.zig"); +const stats = @import("handlers/stats.zig"); +const upstream_health = @import("handlers/upstream_health.zig"); +const upstreams = @import("handlers/upstreams.zig"); +const version = @import("handlers/version.zig"); + +pub const table: []const router.RouteInfo = &.{ + // Monitoring and contract (ruling 18's open set, ruling 19's exemptions). + .{ .method = .GET, .pattern = "/metrics", .auth = .open, .handler = metrics.handle, .rate_limit = .exempt }, + .{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = health.handle, .rate_limit = .exempt }, + .{ .method = .GET, .pattern = "/api/version", .auth = .open, .handler = version.handle }, + .{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .handler = openapi.handle }, + + // Authentication. + .{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .handler = auth.login }, + .{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .handler = auth.logout }, + + // Query log, stats, live stream, lookup. + .{ .method = .GET, .pattern = "/api/queries", .auth = .session, .handler = queries.list }, + .{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .handler = live.stream, .rate_limit = .exempt }, + .{ .method = .GET, .pattern = "/api/stats", .auth = .session, .handler = stats.totals }, + .{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .handler = stats.timeseries }, + .{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .handler = lookup.handle }, + .{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .handler = upstream_health.handle }, + + // Groups. + .{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = groups.list }, + .{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = groups.create }, + .{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.get }, + .{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.update }, + .{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.remove }, + .{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.getSources }, + .{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.putSources }, + + // Blocklist sources. `/api/blocklists/update` is a literal segment; it + // cannot collide with `{id}`, which only matches a positive integer. + .{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.list }, + .{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.create }, + .{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .handler = blocklists.refresh }, + .{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.get }, + .{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.update }, + .{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.remove }, + + // Rules. + .{ .method = .GET, .pattern = "/api/rules", .auth = .session, .handler = rules.list }, + .{ .method = .POST, .pattern = "/api/rules", .auth = .session, .handler = rules.create }, + .{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.get }, + .{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.update }, + .{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.remove }, + + // Local records. + .{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .handler = local.listRecords }, + .{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .handler = local.createRecord }, + .{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.getRecord }, + .{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.updateRecord }, + .{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.removeRecord }, + + // Forward zones. + .{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .handler = local.listZones }, + .{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .handler = local.createZone }, + .{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.getZone }, + .{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.updateZone }, + .{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.removeZone }, + + // Clients (no POST — rows come from DNS activity or import, ruling 9). + .{ .method = .GET, .pattern = "/api/clients", .auth = .session, .handler = clients.list }, + .{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.get }, + .{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.update }, + .{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.remove }, + .{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.listPrefixes }, + .{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.putPrefixes }, + + // Upstreams (restart-required resource). + .{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.list }, + .{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.create }, + .{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.get }, + .{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.update }, + .{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.remove }, + + // Pause and settings. + .{ .method = .GET, .pattern = "/api/pause", .auth = .session, .handler = pause.get }, + .{ .method = .POST, .pattern = "/api/pause", .auth = .session, .handler = pause.post }, + .{ .method = .GET, .pattern = "/api/settings", .auth = .session, .handler = settings.get }, + .{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .handler = settings.put }, +}; + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const std = @import("std"); +const testing = std.testing; + +test "the table carries every endpoint of the milestone" { + try testing.expectEqual(@as(usize, 55), table.len); +} + +test "no two entries claim the same method and pattern" { + for (table, 0..) |a, i| { + for (table[i + 1 ..]) |b| { + if (a.method != b.method) continue; + try testing.expect(!std.mem.eql(u8, a.pattern, b.pattern)); + } + } +} + +test "every pattern lives under /api except the Prometheus endpoint" { + for (table) |route| { + if (std.mem.eql(u8, route.pattern, "/metrics")) continue; + try testing.expect(std.mem.startsWith(u8, route.pattern, "/api/")); + } +} + +test "the open set is exactly ruling 18's exemption list" { + const open = [_][]const u8{ + "/metrics", + "/api/health", + "/api/version", + "/api/openapi.yaml", + "/api/auth/login", + }; + var found: usize = 0; + for (table) |route| { + if (route.auth != .open) continue; + found += 1; + var listed = false; + for (open) |pattern| listed = listed or std.mem.eql(u8, route.pattern, pattern); + try testing.expect(listed); + } + try testing.expectEqual(open.len, found); +} + +test "the limiter exemptions are the monitoring endpoints and the live stream" { + const exempt = [_][]const u8{ + "/metrics", + "/api/health", + "/api/queries/live", + }; + var found: usize = 0; + for (table) |route| { + if (route.rate_limit != .exempt) continue; + found += 1; + var listed = false; + for (exempt) |pattern| listed = listed or std.mem.eql(u8, route.pattern, pattern); + try testing.expect(listed); + } + try testing.expectEqual(exempt.len, found); +} + +test "item routes capture one id and collection routes capture none" { + for (table) |route| { + const captures = std.mem.count(u8, route.pattern, "{id}"); + try testing.expect(captures <= 1); + if (captures == 1) { + try testing.expect(route.method == .GET or route.method == .PUT or route.method == .DELETE); + } + } +} diff --git a/src/web/server.zig b/src/web/server.zig new file mode 100644 index 0000000..b39dfc1 --- /dev/null +++ b/src/web/server.zig @@ -0,0 +1,733 @@ +//! The admin HTTP listener. +//! +//! One `std.http.Server` per connection over our own accept loop: a listener +//! task in the app's group, an inner `Io.Group` of connection tasks, and a +//! keep-alive loop per connection that ends on `error.HttpConnectionClosing`. +//! The shape is lib/std/Build/WebServer.zig:152-185; the shutdown split is +//! tcp_server.zig's, for the same reason. +//! +//! Shutdown takes one of two paths: +//! +//! - `deinit` shuts the listening socket down (which unblocks `accept` with +//! `error.SocketNotListening`) and then shuts every live connection down, so +//! each one unblocks and finishes its response. `serve` drains them. +//! - A canceled `serve` cannot drain: HTTP keep-alive lets a browser hold a +//! connection open indefinitely with no request on it, so waiting would let +//! one idle tab stall the whole process's shutdown. The connection group is +//! canceled instead. +//! +//! Connection slots are fixed and pre-allocated, and each one owns every buffer +//! a request needs, so serving allocates only what a handler asks the +//! per-request arena for. Over capacity the listener answers 503 and closes +//! (ruling 7) rather than queueing: refusing is honest, a queue would hide it. +//! +//! There is no per-request timeout this milestone. The port is LAN-facing and +//! behind the operator's own network; the cancel path, not a timer, is what +//! bounds shutdown. A slow client costs one of 64 slots and nothing else. + +const std = @import("std"); +const net = std.Io.net; +const http = std.http; +const Allocator = std.mem.Allocator; + +const address = @import("../platform/address.zig"); +const api_limiter = @import("api_limiter.zig"); +const auth = @import("auth.zig"); +const clients = @import("../server/clients.zig"); +const db = @import("../storage/db.zig"); +const disk_monitor = @import("../storage/disk_monitor.zig"); +const dns_handler = @import("../server/handler.zig"); +const http_util = @import("http_util.zig"); +const local_tables_mod = @import("../server/local_tables.zig"); +const logger_mod = @import("../storage/logger.zig"); +const manager_mod = @import("../filter/manager.zig"); +const model = @import("../config/model.zig"); +const pause_mod = @import("../server/pause.zig"); +const pool_mod = @import("../upstream/pool.zig"); +const query_sink = @import("../server/query_sink.zig"); +const retention_mod = @import("../storage/retention.zig"); +const router = @import("router.zig"); +const sse = @import("sse.zig"); + +const log = std.log.scoped(.web_server); + +/// Ruling 7. The receive buffer is also the maximum request head +/// (http/Server.zig:32 sets `max_head_len` from it). +const recv_buffer_len = 8 * 1024; +const send_buffer_len = 4 * 1024; + +/// Ruling 7. 64 slots at ~15.7 KiB each is ~1 MiB of fixed connection state. +pub const default_max_connections: u16 = 64; + +/// How much per-request arena a connection keeps between requests. Enough that +/// a normal API response allocates nothing new, small enough that 64 idle +/// connections cost 4 MiB rather than 64. +const arena_retain_bytes = 64 * 1024; + +/// How long the accept loop waits after an unexpected accept failure, so a +/// persistent one cannot turn the loop into a spin. +const retry_delay: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake }; + +const over_capacity_body = "{\"error\":\"too many connections\"}"; +const over_capacity_response = std.fmt.comptimePrint( + "HTTP/1.1 503 Service Unavailable\r\n" ++ + "content-type: " ++ http_util.content_type_json ++ "\r\n" ++ + "connection: close\r\n" ++ + "content-length: {d}\r\n\r\n{s}", + .{ over_capacity_body.len, over_capacity_body }, +); + +/// The verdict of an API rate-limit check. The limiter's own result type, not a +/// copy of it: two structurally identical verdicts would only drift. +pub const LimitVerdict = api_limiter.Result; + +pub const AuthCheckFn = *const fn ( + state: *WebState, + io: std.Io, + request: *const http_util.Request, +) bool; + +pub const LimitCheckFn = *const fn ( + state: *WebState, + io: std.Io, + request: *const http_util.Request, +) LimitVerdict; + +/// Applies a configuration change to the running server (ruling 12: rules, +/// blocklists, groups, clients and prefixes take effect live). Mutation +/// handlers call it through this pointer so their tests can count the calls +/// without a real `Manager`. +pub const ReloadFn = *const fn (state: *WebState, io: std.Io) anyerror!void; + +/// Everything the web layer borrows, assembled by the composition root. Every +/// pointer here outlives the listener task: `app.serve` declares the +/// collaborators above the task group and cancels the group before releasing +/// any of them. +/// +/// The collaborator pointers are optional because the web layer must build and +/// be testable without a whole running server, and because `web.enabled = +/// false` means several of them are never opened at all (ruling 6). A handler +/// that finds the collaborator it needs missing answers 503, the same way it +/// answers a missing snapshot. +pub const WebState = struct { + gpa: Allocator, + web: model.Web = .{}, + + handler: ?*dns_handler.Handler = null, + pause: ?*pause_mod.Pause = null, + tracker: ?*clients.Tracker = null, + manager: ?*manager_mod.Manager = null, + pool: ?*pool_mod.Pool = null, + monitor: ?*disk_monitor.Monitor = null, + /// The local records and forward zones the DNS path reads. The + /// local-records and forward-zones handlers rebuild and swap them + /// (ruling 12). + local_tables: ?*local_tables_mod.LocalTables = null, + logger: ?*logger_mod.Logger = null, + retention: ?*retention_mod.Retention = null, + sessions: ?*auth.Sessions = null, + /// The password hash every auth decision reads. `web` above is the boot + /// configuration and goes stale the moment `PUT /api/settings` changes the + /// password; this holder is what makes the revoked credential stop working + /// without a restart. The composition root seeds it from the boot hash, + /// the settings handler installs replacements, and whoever owns the + /// `WebState` calls `live_hash.deinit`. + live_hash: auth.LiveHash = .{}, + limiter: ?*api_limiter.ApiLimiter = null, + /// The SSE fanout. The sink publishes into it on the DNS hot path; the + /// live-query handler subscribes. + hub: ?*sse.Hub = null, + sink: ?*query_sink.QuerySink = null, + + /// The web task's own connections (m7 ruling 21) — never the DNS path's. + config_db: ?*db.Db = null, + /// Serializes the mutation handlers' work on `config_db`. Connection tasks + /// share the one connection, and `changes()` and `lastInsertRowid()` are + /// connection state that the repositories read after a write, so two + /// concurrent writes would misread each other's row counts. + config_lock: std.Io.Mutex = .init, + querylog_db: ?*db.Db = null, + + version: []const u8 = "", + /// Unix seconds at process start, for uptime. + started_unix: i64 = 0, + + /// The table `dispatch` matches against. Defaults to the shipped one; + /// tests point it at their own. + routes: []const router.RouteInfo = router.routes, + + /// Answers a path no route claimed and that is not under `/api` — the + /// static assets and the SPA fallback (ruling 24). Null means every miss is + /// a JSON 404. + fallback: ?router.HandlerFn = null, + + /// The three policy seams. They are function pointers so that the tests in + /// this layer can drive authentication, rate limiting and reload with + /// doubles instead of a real session store, a real clock and a real + /// `Manager`. The defaults are the production implementations, so the + /// composition root wires collaborators rather than behaviour, and a + /// forgotten wire fails closed rather than open. This is the only + /// indirection of its kind in the web layer; everything else is a direct + /// call. + check_auth: AuthCheckFn = sessionAuth, + check_limit: LimitCheckFn = bucketLimit, + reload_fn: ?ReloadFn = null, +}; + +/// Ruling 17. Authentication is enabled iff a password hash is set — the live +/// one, so a password set through the API locks the routes without a restart. +/// With it set but no session store wired, every session route is refused: the +/// failure mode of a half-wired server must be locked, not open. +pub fn sessionAuth(state: *WebState, io: std.Io, request: *const http_util.Request) bool { + if (!state.live_hash.enabled(io)) return true; + const sessions = state.sessions orelse return false; + const cookie = http_util.cookieValue(request.cookie, auth.cookie_name) orelse return false; + return sessions.validate(io, cookie); +} + +/// Ruling 19. No limiter wired means no limit: the limiter is a defence the +/// operator configures, and its absence must not refuse traffic. +pub fn bucketLimit(state: *WebState, io: std.Io, request: *const http_util.Request) LimitVerdict { + const limiter = state.limiter orelse return .ok; + const now = std.Io.Clock.awake.now(io); + return limiter.check(io, now, address.NetAddress.fromIp(request.peer)); +} + +/// Seam double: refuses nothing. For tests and for a server with no admin +/// password, where `sessionAuth` already answers the same way. +pub fn allowAll(state: *WebState, io: std.Io, request: *const http_util.Request) bool { + _ = state; + _ = io; + _ = request; + return true; +} + +/// Seam double: throttles nothing. +pub fn neverLimit(state: *WebState, io: std.Io, request: *const http_util.Request) LimitVerdict { + _ = state; + _ = io; + _ = request; + return .ok; +} + +pub const Stats = struct { + accepted: std.atomic.Value(u64) = .init(0), + rejected_at_capacity: std.atomic.Value(u64) = .init(0), + rejected_at_shutdown: std.atomic.Value(u64) = .init(0), + accept_errors: std.atomic.Value(u64) = .init(0), + connection_errors: std.atomic.Value(u64) = .init(0), + requests: std.atomic.Value(u64) = .init(0), +}; + +pub const Options = struct { + max_connections: u16 = default_max_connections, +}; + +/// Lifecycle of the accept loop, mirroring tcp_server: `serve` claims +/// `.serving`, `deinit` publishes `.closing`, and the two meet at `stopped`. +const State = enum(u32) { idle, serving, closing }; + +/// `.closing` exists so `deinit` never shuts down a descriptor its own task is +/// about to close. +const ConnState = enum { free, active, closing }; + +/// Why the accept loop stopped, which decides what happens to the connections +/// still in flight. +const Stop = enum { closing, canceled }; + +const Claim = union(enum) { + slot: usize, + at_capacity, + shutting_down, +}; + +pub const Server = struct { + state: *WebState, + listener: net.Server, + conns: []Conn, + mutex: std.Io.Mutex, + /// Guarded by `mutex`, set in the same critical section that shuts the live + /// connections down. + shutdown_begun: bool, + stats: Stats, + run_state: std.atomic.Value(State), + stopped: std.Io.Event, + + /// One slot's fixed cost. The head copies exist because every string in + /// `request.head` dies on the first body read (http/Server.zig:594). + pub const Conn = struct { + recv_buf: [recv_buffer_len]u8, + send_buf: [send_buffer_len]u8, + target_buf: [http_util.max_target_len]u8, + cookie_buf: [http_util.max_cookie_len]u8, + accept_encoding_buf: [http_util.max_header_value_len]u8, + if_none_match_buf: [http_util.max_header_value_len]u8, + /// Per-request working memory, reset between requests on the same + /// connection so a keep-alive client cannot grow it without bound. + arena: std.heap.ArenaAllocator, + stream: net.Stream, + peer: net.IpAddress, + /// Guarded by `Server.mutex`. + conn_state: ConnState, + }; + + pub const ListenError = net.IpAddress.ListenError || error{OutOfMemory}; + + pub fn listen( + gpa: Allocator, + io: std.Io, + listen_address: net.IpAddress, + state: *WebState, + options: Options, + ) ListenError!Server { + std.debug.assert(options.max_connections > 0); + + const conns = try gpa.alloc(Conn, options.max_connections); + errdefer gpa.free(conns); + for (conns) |*conn| { + conn.conn_state = .free; + conn.arena = .init(gpa); + } + + const listener = try listen_address.listen(io, .{ .reuse_address = true }); + + return .{ + .state = state, + .listener = listener, + .conns = conns, + .mutex = .init, + .shutdown_begun = false, + .stats = .{}, + .run_state = .init(.idle), + .stopped = .unset, + }; + } + + /// The kernel-assigned address. A port of 0 in `listen` resolves here. + pub fn boundAddress(self: *const Server) net.IpAddress { + return self.listener.socket.address; + } + + /// Accept loop. Returns when the task is canceled or `deinit` stops it. + pub fn serve(self: *Server, io: std.Io) void { + if (self.run_state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return; + + var group: std.Io.Group = .init; + switch (self.acceptLoop(io, &group)) { + // `deinit` shut every live connection down before it published + // `.closing`, so each one is unblocked and finishing on its own. + // Awaiting them means a half-written response still goes out whole. + .closing => { + const prev = io.swapCancelProtection(.blocked); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; + _ = io.swapCancelProtection(prev); + }, + // Nothing has shut these connections down, and an idle keep-alive + // connection has no deadline of its own, so draining could wait + // forever. Cancel joins, so the slots are quiet by the time `serve` + // returns; the price is the one response that was mid-write. + .canceled => group.cancel(io), + } + + self.stopped.set(io); + } + + pub fn deinit(self: *Server, gpa: Allocator, io: std.Io) void { + const was_serving = self.run_state.swap(.closing, .acq_rel) == .serving; + + // Shutting the listening socket down is the documented way to unblock a + // pending `accept`: it fails with `error.SocketNotListening`. + const listener: net.Stream = .{ .socket = self.listener.socket }; + listener.shutdown(io, .both) catch |err| { + log.debug("web listener shutdown failed: {t}", .{err}); + }; + + self.beginShutdown(io); + + if (was_serving) self.stopped.waitUncancelable(io); + + self.listener.deinit(io); + for (self.conns) |*conn| conn.arena.deinit(); + gpa.free(self.conns); + self.* = undefined; + } + + fn acceptLoop(self: *Server, io: std.Io, group: *std.Io.Group) Stop { + while (self.run_state.load(.acquire) == .serving) { + const stream = self.listener.accept(io) catch |err| switch (err) { + error.Canceled => return .canceled, + error.SocketNotListening => return .closing, + else => { + bump(&self.stats.accept_errors); + log.debug("web accept failed: {t}", .{err}); + retry_delay.sleep(io) catch return .canceled; + continue; + }, + }; + + const index = switch (self.claim(io, stream)) { + .slot => |index| index, + .at_capacity => { + bump(&self.stats.rejected_at_capacity); + refuse(io, stream); + continue; + }, + .shutting_down => { + bump(&self.stats.rejected_at_shutdown); + stream.close(io); + return .closing; + }, + }; + + group.concurrent(io, serveConn, .{ self, io, index }) catch |err| switch (err) { + error.ConcurrencyUnavailable => { + bump(&self.stats.rejected_at_capacity); + self.finish(io, index); + continue; + }, + }; + + bump(&self.stats.accepted); + } + + // The loop condition failed, which only `deinit` can cause. + return .closing; + } + + /// Ruling 7: over capacity the client is told so, never silently dropped. + /// + /// The response is written from the accept loop, because refusing must not + /// consume the slot that is missing. It is ~130 bytes — one socket buffer — + /// so a peer that never reads still cannot stall the loop. + /// + /// The close that follows does not drain the client's request first, so + /// Linux may follow the response with an RST and a client that had already + /// sent its request can lose the 503 and see a reset instead. Draining + /// would mean a blocking read on the accept loop with no bound but the + /// client's goodwill, which is a worse failure than a lost error page on a + /// server that is already at capacity. + fn refuse(io: std.Io, stream: net.Stream) void { + var buf: [over_capacity_response.len]u8 = undefined; + var writer = stream.writer(io, &buf); + writer.interface.writeAll(over_capacity_response) catch {}; + writer.interface.flush() catch {}; + stream.close(io); + } + + fn serveConn(self: *Server, io: std.Io, index: usize) void { + defer self.finish(io, index); + + const conn = &self.conns[index]; + var reader = conn.stream.reader(io, &conn.recv_buf); + var writer = conn.stream.writer(io, &conn.send_buf); + var connection: http.Server = .init(&reader.interface, &writer.interface); + + while (connection.reader.state == .ready) { + var request = connection.receiveHead() catch |err| switch (err) { + // The normal end of a keep-alive connection. + error.HttpConnectionClosing => return, + // Cancellation and a vanished client both land here; neither is + // worth a counter. + error.ReadFailed => return, + error.HttpHeadersOversize => { + bump(&self.stats.connection_errors); + return; + }, + error.HttpRequestTruncated, error.HttpHeadersInvalid => { + bump(&self.stats.connection_errors); + return; + }, + }; + + // RFC 9110 §8.6: a request with neither content-length nor + // transfer-encoding has an empty body, but std leaves the head + // saying "unknown" and `discardBody` asserts on it inside every + // `respond` (http/Server.zig:631) — `curl -X POST` panics the + // process. A zero length is what the head means, and it satisfies + // every downstream reader: `bodyReader` (http.zig:445) goes + // straight to `.ready` on a zero content-length. + if (request.head.method.requestHasBody() and + request.head.transfer_encoding == .none and + request.head.content_length == null) + { + request.head.content_length = 0; + } + + bump(&self.stats.requests); + // Retained with a limit, not wholesale: a single 1 MiB body would + // otherwise keep a megabyte per slot alive for as long as the + // browser holds the connection. + _ = conn.arena.reset(.{ .retain_with_limit = arena_retain_bytes }); + + self.handleRequest(io, conn, &request) catch |err| switch (err) { + // Ruling 28: the peer went away mid-response. Normal. + error.WriteFailed => return, + error.HttpExpectationFailed, error.OutOfMemory => { + bump(&self.stats.connection_errors); + return; + }, + }; + } + } + + /// Builds the request view and dispatches it. Every string a handler may + /// touch after a body read is copied here first (ruling 25). + fn handleRequest( + self: *Server, + io: std.Io, + conn: *Conn, + request: *http.Server.Request, + ) http_util.HandlerError!void { + const arena = conn.arena.allocator(); + + const target = request.head.target; + if (target.len > conn.target_buf.len) { + var view = bareRequest(request, conn, arena); + return http_util.respondError(&view, .uri_too_long, "target too long"); + } + @memcpy(conn.target_buf[0..target.len], target); + const copied = conn.target_buf[0..target.len]; + + const split = std.mem.findScalar(u8, copied, '?') orelse copied.len; + const raw_path = copied[0..split]; + const query = if (split == copied.len) copied[split..] else copied[split + 1 ..]; + + const cookie = copyHeader(request, "cookie", &conn.cookie_buf); + const accept_encoding = copyHeader(request, "accept-encoding", &conn.accept_encoding_buf); + const if_none_match = copyHeader(request, "if-none-match", &conn.if_none_match_buf); + + // Decoding is destructive, so it runs on a copy: W8's asset lookup needs + // the raw path to match embedded file names byte for byte. + const decodable = arena.dupe(u8, raw_path) catch return error.OutOfMemory; + const path = http_util.parsePath(decodable) catch { + var view = bareRequest(request, conn, arena); + return http_util.respondError(&view, .bad_request, "malformed path"); + }; + + var view: http_util.Request = .{ + .http = request, + .method = request.head.method, + .path = path, + .raw_path = raw_path, + .query = query, + .id = null, + .cookie = cookie, + .accept_encoding = accept_encoding, + .if_none_match = if_none_match, + .peer = conn.peer, + .arena = arena, + }; + return router.dispatch(self.state, io, &view); + } + + /// A request view for the errors that are decided before parsing finishes. + fn bareRequest(request: *http.Server.Request, conn: *Conn, arena: Allocator) http_util.Request { + return .{ + .http = request, + .method = request.head.method, + .path = .empty, + .raw_path = "", + .query = "", + .id = null, + .cookie = "", + .accept_encoding = "", + .if_none_match = "", + .peer = conn.peer, + .arena = arena, + }; + } + + fn claim(self: *Server, io: std.Io, stream: net.Stream) Claim { + // Uncancelable: this section takes no Io and never blocks on a peer, so + // losing the lock mid-update would leak a slot for nothing. + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + const outcome = decideClaim(self.conns, self.shutdown_begun); + switch (outcome) { + .slot => |index| { + self.conns[index].stream = stream; + self.conns[index].peer = stream.socket.address; + self.conns[index].conn_state = .active; + }, + .at_capacity, .shutting_down => {}, + } + return outcome; + } + + fn finish(self: *Server, io: std.Io, index: usize) void { + const conn = &self.conns[index]; + + self.mutex.lockUncancelable(io); + conn.conn_state = .closing; + self.mutex.unlock(io); + + // The socket is released even when this task is being torn down: the + // next cancelable call would otherwise skip the close. + const prev = io.swapCancelProtection(.blocked); + conn.stream.close(io); + _ = io.swapCancelProtection(prev); + + self.mutex.lockUncancelable(io); + conn.conn_state = .free; + self.mutex.unlock(io); + } + + /// Closes the door on new connections and unblocks the live ones under one + /// hold of the mutex, so no `claim` can slip between the two. + fn beginShutdown(self: *Server, io: std.Io) void { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + self.shutdown_begun = true; + + for (self.conns) |*conn| { + if (conn.conn_state != .active) continue; + conn.stream.shutdown(io, .both) catch |err| { + log.debug("web connection shutdown failed: {t}", .{err}); + }; + } + } +}; + +/// Copies one header value into `buf`. A value too long for its budget reads as +/// absent: the three headers this applies to are a session cookie, an +/// `accept-encoding` and an `if-none-match`, and losing any of them degrades to +/// unauthenticated, uncompressed and unconditional — never to a wrong answer. +fn copyHeader(request: *http.Server.Request, name: []const u8, buf: []u8) []const u8 { + var it = request.iterateHeaders(); + while (it.next()) |header| { + if (!std.ascii.eqlIgnoreCase(header.name, name)) continue; + if (header.value.len > buf.len) return ""; + @memcpy(buf[0..header.value.len], header.value); + return buf[0..header.value.len]; + } + return ""; +} + +/// The whole claim rule, without the mutex, so it is testable without a backend. +fn decideClaim(conns: []const Server.Conn, shutdown_begun: bool) Claim { + if (shutdown_begun) return .shutting_down; + for (conns, 0..) |*conn, index| { + if (conn.conn_state == .free) return .{ .slot = index }; + } + return .at_capacity; +} + +fn bump(counter: *std.atomic.Value(u64)) void { + _ = counter.fetchAdd(1, .monotonic); +} + +/// The composition root's entry point: bind, serve, release. +/// +/// A bind failure is warned and swallowed. The admin UI failing to come up must +/// not stop nxdns answering DNS, which is what the box is for; the operator +/// sees the warning and the DNS side keeps serving. +pub fn serve(state: *WebState, io: std.Io) void { + const bind_address = net.IpAddress.parse(state.web.bind, state.web.port) catch { + log.warn("web.bind '{s}' is not an IP address; the web interface is disabled", .{state.web.bind}); + return; + }; + + var server: Server = Server.listen(state.gpa, io, bind_address, state, .{}) catch |err| { + log.warn("web interface cannot listen on {s}:{d}: {t}", .{ state.web.bind, state.web.port, err }); + return; + }; + defer server.deinit(state.gpa, io); + + log.info("web interface listening on {f}", .{server.boundAddress()}); + server.serve(io); +} + +const testing = std.testing; + +fn testConns(count: usize) ![]Server.Conn { + const conns = try testing.allocator.alloc(Server.Conn, count); + for (conns) |*conn| conn.conn_state = .free; + return conns; +} + +test "the connection pool hands out every slot once, then refuses" { + const conns = try testConns(2); + defer testing.allocator.free(conns); + + try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot); + conns[0].conn_state = .active; + try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot); + conns[1].conn_state = .active; + try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false))); +} + +test "a closing slot is not reused until it is free" { + const conns = try testConns(1); + defer testing.allocator.free(conns); + + conns[0].conn_state = .closing; + try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false))); + conns[0].conn_state = .free; + try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot); +} + +test "shutdown outranks capacity and does not consume the slot" { + const conns = try testConns(1); + defer testing.allocator.free(conns); + + try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true))); + try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot); +} + +test "the over-capacity response is a well formed 503" { + try testing.expect(std.mem.startsWith(u8, over_capacity_response, "HTTP/1.1 503 ")); + const split = std.mem.findPosLinear(u8, over_capacity_response, 0, "\r\n\r\n").?; + try testing.expectEqualStrings(over_capacity_body, over_capacity_response[split + 4 ..]); +} + +test "an unconfigured password leaves every route open" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + + var state: WebState = .{ .gpa = testing.allocator }; + const request = testRequest(); + try testing.expect(sessionAuth(&state, threaded.io(), &request)); +} + +test "a configured password with no session store refuses rather than opens" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + + var state: WebState = .{ .gpa = testing.allocator, .live_hash = .init("$argon2id$...") }; + const request = testRequest(); + try testing.expect(!sessionAuth(&state, threaded.io(), &request)); +} + +test "an unwired limiter throttles nothing" { + var state: WebState = .{ .gpa = testing.allocator }; + const request = testRequest(); + try testing.expect(bucketLimit(&state, undefined, &request).allowed); +} + +test "the seam doubles are usable in place of the production checks" { + var state: WebState = .{ .gpa = testing.allocator, .check_auth = allowAll, .check_limit = neverLimit }; + const request = testRequest(); + try testing.expect(state.check_auth(&state, undefined, &request)); + try testing.expect(state.check_limit(&state, undefined, &request).allowed); +} + +/// `io` is never reached on these paths, so the tests above pass `undefined`. +fn testRequest() http_util.Request { + return .{ + .http = undefined, + .method = .GET, + .path = .empty, + .raw_path = "/api/groups", + .query = "", + .id = null, + .cookie = "", + .accept_encoding = "", + .if_none_match = "", + .peer = .{ .ip4 = .loopback(0) }, + .arena = testing.allocator, + }; +} diff --git a/src/web/server_integration_test.zig b/src/web/server_integration_test.zig new file mode 100644 index 0000000..2028d7c --- /dev/null +++ b/src/web/server_integration_test.zig @@ -0,0 +1,648 @@ +//! Loopback tests for `server.zig` and `router.zig`. +//! +//! This lives in its own file because it needs `@import("build_options")`, +//! which only exists when the compilation is driven by build.zig. The body is +//! compiled by every `zig build test` run, so it cannot rot, and skips at run +//! time unless `-Dintegration` is passed. +//! +//! Hermetic: one listener and one or two clients on 127.0.0.1, handlers that +//! touch nothing but the request. No stream read in 0.16.0 takes a timeout, so +//! the whole client side of each test runs as one task raced against a budget +//! and nothing can hang. + +const std = @import("std"); +const build_options = @import("build_options"); +const net = std.Io.net; + +const http_util = @import("http_util.zig"); +const router = @import("router.zig"); +const server = @import("server.zig"); + +const testing = std.testing; + +const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(5), .clock = .awake }; + +/// Long enough that a loopback round trip cannot lose to scheduling, short +/// enough that the cancellation test stays quick. +const settle: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(200), .clock = .awake }; + +fn okHandler( + state: *server.WebState, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void { + _ = state; + _ = io; + return http_util.respondBytes(request, .ok, "pong", http_util.content_type_text, &.{}); +} + +/// Echoes the body length back, so a test can prove the body arrived whole and +/// that the cap fires before a handler ever sees an oversize one. +fn echoLengthHandler( + state: *server.WebState, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void { + _ = state; + _ = io; + const body = http_util.readBody(request) catch |err| switch (err) { + error.TooLarge => return http_util.respondError(request, .payload_too_large, "body too large"), + error.OutOfMemory => return error.OutOfMemory, + error.ReadFailed => return error.WriteFailed, + error.WriteFailed => return error.WriteFailed, + error.HttpExpectationFailed => return error.HttpExpectationFailed, + }; + var buf: [32]u8 = undefined; + const text = std.fmt.bufPrint(&buf, "{d}", .{body.len}) catch unreachable; + return http_util.respondBytes(request, .ok, text, http_util.content_type_text, &.{}); +} + +/// Answers with the decoded query value, proving the router hands handlers a +/// target copy that survives the head being invalidated. +fn echoDomainHandler( + state: *server.WebState, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void { + _ = state; + _ = io; + var buf: [http_util.max_query_value_len]u8 = undefined; + const value = http_util.queryValue(request.query, "domain", &buf) catch { + return http_util.respondError(request, .bad_request, "bad query"); + } orelse ""; + return http_util.respondBytes(request, .ok, value, http_util.content_type_text, &.{}); +} + +/// Reads the body first and only then looks at the path, which is exactly the +/// order that would break without the head copy (ruling 25). +fn bodyThenPathHandler( + state: *server.WebState, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void { + _ = state; + _ = io; + _ = http_util.readBody(request) catch return error.WriteFailed; + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + writer.print("{s}|{?d}", .{ request.raw_path, request.id }) catch unreachable; + return http_util.respondBytes(request, .ok, writer.buffered(), http_util.content_type_text, &.{}); +} + +const test_routes = [_]router.RouteInfo{ + .{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = okHandler, .rate_limit = .exempt }, + .{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = okHandler }, + .{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = echoLengthHandler }, + .{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = bodyThenPathHandler }, + .{ .method = .GET, .pattern = "/api/lookup", .auth = .open, .handler = echoDomainHandler }, +}; + +fn denyAll(state: *server.WebState, io: std.Io, request: *const http_util.Request) bool { + _ = state; + _ = io; + _ = request; + return false; +} + +fn alwaysLimited(state: *server.WebState, io: std.Io, request: *const http_util.Request) server.LimitVerdict { + _ = state; + _ = io; + _ = request; + return .{ .allowed = false, .retry_after_s = 42 }; +} + +fn testState(gpa: std.mem.Allocator) server.WebState { + return .{ + .gpa = gpa, + .routes = &test_routes, + .check_auth = server.allowAll, + .check_limit = server.neverLimit, + }; +} + +const Outcome = union(enum) { + work: anyerror!void, + expiry: std.Io.Cancelable!void, +}; + +fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void { + return duration.sleep(io); +} + +/// Runs the client side under a budget so a server that never answers fails the +/// test instead of hanging the run. +fn bounded(io: std.Io, comptime f: anytype, args: std.meta.ArgsTuple(@TypeOf(f))) !void { + var outcomes: [2]Outcome = undefined; + var race: std.Io.Select(Outcome) = .init(io, &outcomes); + defer race.cancelDiscard(); + + try race.concurrent(.work, f, args); + try race.concurrent(.expiry, expire, .{ io, budget }); + + switch (try race.await()) { + .work => |result| return result, + .expiry => |result| { + try result; + return error.TestTimedOut; + }, + } +} + +/// One open connection with a reader and a writer, which is all these tests +/// need of an HTTP client. +const Conn = struct { + stream: net.Stream, + reader: net.Stream.Reader, + writer: net.Stream.Writer, + read_buf: [8192]u8 = undefined, + write_buf: [4096]u8 = undefined, + /// Header lines are copied here because each `takeDelimiterInclusive` + /// invalidates the previous line's slice into the read buffer. + head_buf: [4096]u8 = undefined, + + fn connect(self: *Conn, io: std.Io, address: net.IpAddress) !void { + self.stream = try address.connect(io, .{ .mode = .stream }); + self.reader = self.stream.reader(io, &self.read_buf); + self.writer = self.stream.writer(io, &self.write_buf); + } + + fn close(self: *Conn, io: std.Io) void { + self.stream.close(io); + } + + fn send(self: *Conn, request: []const u8) !void { + try self.writer.interface.writeAll(request); + try self.writer.interface.flush(); + } + + /// Reads one response: head to the blank line, then exactly + /// `content-length` bytes. Every response these tests provoke carries one. + fn receive(self: *Conn, out: []u8) !Response { + var head_len: usize = 0; + while (true) { + const raw = try self.reader.interface.takeDelimiterInclusive('\n'); + const line = std.mem.trimEnd(u8, raw, "\r\n"); + if (line.len == 0) break; + if (head_len + line.len + 1 > self.head_buf.len) return error.TestHeadTooLarge; + @memcpy(self.head_buf[head_len..][0..line.len], line); + head_len += line.len; + self.head_buf[head_len] = '\n'; + head_len += 1; + } + const head = self.head_buf[0..head_len]; + const status = try parseStatus(head); + const length = try contentLength(head); + if (length > out.len) return error.TestResponseTooLarge; + const body = out[0..length]; + try self.reader.interface.readSliceAll(body); + return .{ .status = status, .head = head, .body = body }; + } +}; + +const Response = struct { + status: u16, + /// Borrows the connection's read buffer; valid until the next receive. + head: []const u8, + body: []const u8, + + fn header(self: Response, name: []const u8) ?[]const u8 { + var lines = std.mem.splitScalar(u8, self.head, '\n'); + _ = lines.next(); + while (lines.next()) |line| { + const colon = std.mem.findScalar(u8, line, ':') orelse continue; + if (!std.ascii.eqlIgnoreCase(std.mem.trim(u8, line[0..colon], " "), name)) continue; + return std.mem.trim(u8, line[colon + 1 ..], " "); + } + return null; + } +}; + +fn parseStatus(head: []const u8) !u16 { + const first_space = std.mem.findScalar(u8, head, ' ') orelse return error.TestBadResponse; + const rest = head[first_space + 1 ..]; + const second_space = std.mem.findScalar(u8, rest, ' ') orelse rest.len; + return std.fmt.parseInt(u16, rest[0..second_space], 10) catch error.TestBadResponse; +} + +fn contentLength(head: []const u8) !usize { + var lines = std.mem.splitScalar(u8, head, '\n'); + while (lines.next()) |line| { + const colon = std.mem.findScalar(u8, line, ':') orelse continue; + if (!std.ascii.eqlIgnoreCase(std.mem.trim(u8, line[0..colon], " "), "content-length")) continue; + return std.fmt.parseInt(usize, std.mem.trim(u8, line[colon + 1 ..], " "), 10) catch error.TestBadResponse; + } + return error.TestNoContentLength; +} + +fn get(path: []const u8, buf: []u8) []const u8 { + return std.fmt.bufPrint(buf, "GET {s} HTTP/1.1\r\nhost: t\r\n\r\n", .{path}) catch unreachable; +} + +/// Starts a listener on 127.0.0.1:0 with `state` and runs `f` against it under +/// the budget, then shuts the listener down through the drain path. +fn withServer( + gpa: std.mem.Allocator, + io: std.Io, + state: *server.WebState, + max_connections: u16, + comptime f: anytype, + extra: anytype, +) !server.Stats { + const listen_address: net.IpAddress = try .parse("127.0.0.1", 0); + var web = try server.Server.listen(gpa, io, listen_address, state, .{ .max_connections = max_connections }); + const address = web.boundAddress(); + + var group: std.Io.Group = .init; + try group.concurrent(io, server.Server.serve, .{ &web, io }); + + const result = bounded(io, f, .{ io, address } ++ extra); + + const stats: server.Stats = .{ + .accepted = .init(web.stats.accepted.load(.monotonic)), + .rejected_at_capacity = .init(web.stats.rejected_at_capacity.load(.monotonic)), + .rejected_at_shutdown = .init(web.stats.rejected_at_shutdown.load(.monotonic)), + .accept_errors = .init(web.stats.accept_errors.load(.monotonic)), + .connection_errors = .init(web.stats.connection_errors.load(.monotonic)), + .requests = .init(web.stats.requests.load(.monotonic)), + }; + + web.deinit(gpa, io); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; + + try result; + return stats; +} + +fn twoRequestsOnOneConnection(io: std.Io, address: net.IpAddress) anyerror!void { + var conn: Conn = undefined; + try conn.connect(io, address); + defer conn.close(io); + + var body_buf: [256]u8 = undefined; + for (0..2) |_| { + var request_buf: [128]u8 = undefined; + try conn.send(get("/api/health", &request_buf)); + const response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + try testing.expectEqualStrings("pong", response.body); + } +} + +test "one connection carries two requests" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var state = testState(gpa); + const stats = try withServer(gpa, io, &state, 4, twoRequestsOnOneConnection, .{}); + + // One accept for two requests is the whole point of keep-alive. + try testing.expectEqual(@as(u64, 1), stats.accepted.load(.monotonic)); + try testing.expectEqual(@as(u64, 2), stats.requests.load(.monotonic)); + try testing.expectEqual(@as(u64, 0), stats.connection_errors.load(.monotonic)); +} + +fn routingMatrix(io: std.Io, address: net.IpAddress) anyerror!void { + var conn: Conn = undefined; + try conn.connect(io, address); + defer conn.close(io); + + var body_buf: [512]u8 = undefined; + var request_buf: [256]u8 = undefined; + + try conn.send(get("/api/nope", &request_buf)); + var response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 404), response.status); + try testing.expectEqualStrings("{\"error\":\"not found\"}", response.body); + + try conn.send("DELETE /api/groups HTTP/1.1\r\nhost: t\r\n\r\n"); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 405), response.status); + try testing.expectEqualStrings("GET, POST", response.header("allow").?); + + // '+' is a space, %2E is a literal dot: both survive the round trip. + try conn.send(get("/api/lookup?domain=a+b%2Ecom", &request_buf)); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + try testing.expectEqualStrings("a b.com", response.body); + + // A truncated escape is a 400, not a value with a stray percent in it. + try conn.send(get("/api/lookup?domain=abc%2", &request_buf)); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 400), response.status); + + // A path deeper than the segment budget is refused before matching. + try conn.send(get("/1/2/3/4/5/6/7/8/9", &request_buf)); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 400), response.status); +} + +test "routing answers 404, 405 with allow, and rejects malformed targets" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var state = testState(gpa); + const stats = try withServer(gpa, io, &state, 4, routingMatrix, .{}); + try testing.expectEqual(@as(u64, 5), stats.requests.load(.monotonic)); +} + +fn postBody(io: std.Io, address: net.IpAddress, length: usize, expected_status: u16) anyerror!void { + var conn: Conn = undefined; + try conn.connect(io, address); + defer conn.close(io); + + var head_buf: [128]u8 = undefined; + const head = try std.fmt.bufPrint( + &head_buf, + "POST /api/groups HTTP/1.1\r\nhost: t\r\ncontent-length: {d}\r\n\r\n", + .{length}, + ); + try conn.writer.interface.writeAll(head); + + const chunk = [_]u8{'x'} ** 4096; + var sent: usize = 0; + while (sent < length) { + const n = @min(chunk.len, length - sent); + // A refused body ends the connection, so the tail of a rejected write + // is expected to fail; the response is what the test reads. + conn.writer.interface.writeAll(chunk[0..n]) catch break; + sent += n; + } + conn.writer.interface.flush() catch {}; + + var body_buf: [256]u8 = undefined; + const response = try conn.receive(&body_buf); + try testing.expectEqual(expected_status, response.status); +} + +test "a body inside the cap is delivered whole" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var state = testState(gpa); + _ = try withServer(gpa, io, &state, 4, postBody, .{ @as(usize, 64 * 1024), @as(u16, 200) }); +} + +test "a body over the cap is 413, not a buffered megabyte" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var state = testState(gpa); + _ = try withServer( + gpa, + io, + &state, + 4, + postBody, + .{ http_util.max_body_bytes + 1, @as(u16, 413) }, + ); +} + +fn postWithoutLength(io: std.Io, address: net.IpAddress) anyerror!void { + var conn: Conn = undefined; + try conn.connect(io, address); + defer conn.close(io); + + var body_buf: [256]u8 = undefined; + try conn.send("POST /api/groups HTTP/1.1\r\nhost: t\r\n\r\n"); + const response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + try testing.expectEqualStrings("0", response.body); + + // A fresh connection proves the listener outlived the request; before the + // head normalization it died on http/Server.zig:631's assert. + var second: Conn = undefined; + try second.connect(io, address); + defer second.close(io); + + var request_buf: [128]u8 = undefined; + try second.send(get("/api/health", &request_buf)); + const again = try second.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), again.status); +} + +test "a POST with no content-length and no transfer-encoding is an empty body, not a crash" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var state = testState(gpa); + const stats = try withServer(gpa, io, &state, 4, postWithoutLength, .{}); + try testing.expectEqual(@as(u64, 0), stats.connection_errors.load(.monotonic)); +} + +fn bodyThenTarget(io: std.Io, address: net.IpAddress) anyerror!void { + var conn: Conn = undefined; + try conn.connect(io, address); + defer conn.close(io); + + try conn.send("PUT /api/groups/17 HTTP/1.1\r\nhost: t\r\ncontent-length: 4\r\n\r\nabcd"); + + var body_buf: [128]u8 = undefined; + const response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + try testing.expectEqualStrings("/api/groups/17|17", response.body); +} + +test "the target survives a body read" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var state = testState(gpa); + _ = try withServer(gpa, io, &state, 4, bodyThenTarget, .{}); +} + +fn refusedOverCapacity(io: std.Io, address: net.IpAddress) anyerror!void { + // Hold the only slot with an idle keep-alive connection, so the second + // client meets a full table rather than a race. + var held: Conn = undefined; + try held.connect(io, address); + defer held.close(io); + + var request_buf: [128]u8 = undefined; + var body_buf: [256]u8 = undefined; + try held.send(get("/api/health", &request_buf)); + const first = try held.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), first.status); + + var overflow: Conn = undefined; + try overflow.connect(io, address); + defer overflow.close(io); + + try overflow.send(get("/api/health", &request_buf)); + const refused = try overflow.receive(&body_buf); + try testing.expectEqual(@as(u16, 503), refused.status); + try testing.expectEqualStrings("{\"error\":\"too many connections\"}", refused.body); +} + +test "a connection over the cap is told 503, not silently dropped" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var state = testState(gpa); + const stats = try withServer(gpa, io, &state, 1, refusedOverCapacity, .{}); + try testing.expectEqual(@as(u64, 1), stats.accepted.load(.monotonic)); + try testing.expectEqual(@as(u64, 1), stats.rejected_at_capacity.load(.monotonic)); +} + +fn deniedAndLimited(io: std.Io, address: net.IpAddress) anyerror!void { + var conn: Conn = undefined; + try conn.connect(io, address); + defer conn.close(io); + + var request_buf: [128]u8 = undefined; + var body_buf: [256]u8 = undefined; + + // The limiter runs before authentication, so a limited request is 429 even + // though the same request would also have failed the session check. + try conn.send(get("/api/groups", &request_buf)); + var response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 429), response.status); + try testing.expectEqualStrings("42", response.header("retry-after").?); + + // Ruling 19: the monitoring endpoints are exempt and answer normally. + try conn.send(get("/api/health", &request_buf)); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); +} + +test "the limiter and the session check are applied in that order" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var state = testState(gpa); + state.check_auth = denyAll; + state.check_limit = alwaysLimited; + _ = try withServer(gpa, io, &state, 4, deniedAndLimited, .{}); +} + +fn unauthenticated(io: std.Io, address: net.IpAddress) anyerror!void { + var conn: Conn = undefined; + try conn.connect(io, address); + defer conn.close(io); + + var request_buf: [128]u8 = undefined; + var body_buf: [256]u8 = undefined; + + try conn.send(get("/api/groups", &request_buf)); + var response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 401), response.status); + + // An open route stays reachable so the SPA shell can show a login form. + try conn.send(get("/api/health", &request_buf)); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); +} + +test "a session route without a session is 401 and an open route is not" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var state = testState(gpa); + state.check_auth = denyAll; + _ = try withServer(gpa, io, &state, 4, unauthenticated, .{}); +} + +/// Opens a connection, answers one request on it, and then leaves it idle and +/// open — the shape a browser tab holds, and the one that must not be able to +/// stall shutdown. +/// Returns plain `void`, not an error union: a group task must be coercible to +/// `Cancelable!void`, so the outcome travels in `failed` instead. +fn holdIdleConnection(io: std.Io, address: net.IpAddress, opened: *std.Io.Event, failed: *bool) void { + holdIdleConnectionInner(io, address, opened) catch { + failed.* = true; + opened.set(io); + }; +} + +fn holdIdleConnectionInner(io: std.Io, address: net.IpAddress, opened: *std.Io.Event) anyerror!void { + var conn: Conn = undefined; + try conn.connect(io, address); + defer conn.close(io); + + var request_buf: [128]u8 = undefined; + var body_buf: [256]u8 = undefined; + try conn.send(get("/api/health", &request_buf)); + const response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + + opened.set(io); + // Nothing more is sent. The connection sits in `receiveHead`, which is + // where cancellation has to reach it. + settle.sleep(io) catch {}; +} + +test "cancellation returns promptly with an idle keep-alive connection open" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var state = testState(gpa); + const listen_address: net.IpAddress = try .parse("127.0.0.1", 0); + var web = try server.Server.listen(gpa, io, listen_address, &state, .{ .max_connections = 4 }); + const address = web.boundAddress(); + + var group: std.Io.Group = .init; + try group.concurrent(io, server.Server.serve, .{ &web, io }); + + var opened: std.Io.Event = .unset; + var failed = false; + var client: std.Io.Group = .init; + try client.concurrent(io, holdIdleConnection, .{ io, address, &opened, &failed }); + opened.wait(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; + try testing.expect(!failed); + + // The listener task is canceled with a client parked in `receiveHead`. If + // the cancel path awaited the connection group instead of canceling it, + // this would block until the client hung up, which the budget below would + // catch as a failure. + const start = std.Io.Clock.awake.now(io); + group.cancel(io); + const elapsed = start.durationTo(std.Io.Clock.awake.now(io)); + + client.cancel(io); + web.deinit(gpa, io); + + try testing.expect(elapsed.toMilliseconds() < settle.raw.toMilliseconds()); +} diff --git a/src/web/sse.zig b/src/web/sse.zig new file mode 100644 index 0000000..3cd89c5 --- /dev/null +++ b/src/web/sse.zig @@ -0,0 +1,439 @@ +//! Live query fanout for `GET /api/queries/live` (PLAN §11.4:455). +//! +//! The DNS query path publishes through `QuerySink`, which calls `publish` +//! before it hands the same entry to the logger: the event stream must never +//! wait on a database. `publish` therefore copies and returns — it allocates +//! nothing, touches no I/O, and holds one mutex across a scan of 32 slots. +//! +//! A subscriber that cannot keep up loses its stream rather than the queries: +//! a full ring sets `overflowed`, the subscriber task sees the flag and ends +//! the response, and the browser's `EventSource` reconnects on its own. +//! +//! `logger.Entry` carries its own bytes, so a ring slot is a plain copy with +//! nothing borrowed from the query that produced it. + +const std = @import("std"); + +const logger = @import("../storage/logger.zig"); + +pub const Entry = logger.Entry; + +/// Concurrent live streams. The per-IP cap (`web.sse_max_connections_per_ip`) +/// keeps one client from taking all of them; `subscribe` returning null is the +/// backstop and answers 503. +pub const max_subscribers = 32; + +/// Entries one subscriber may fall behind by. At household query rates this is +/// several seconds of slack on a stalled TCP connection. +pub const ring_capacity = 64; + +pub const SubscriberId = enum(u8) { _ }; + +/// What `wait` returns: an entry (or the overflow flag) is ready, or the +/// caller's timeout passed and it owes the client a heartbeat. +pub const Wake = enum { ready, timeout }; + +pub const Hub = struct { + /// Guards every field of every slot. `publish` runs on the DNS hot path, + /// so the critical section is copies and flag writes only. + mutex: std.Io.Mutex, + slots: [max_subscribers]Slot, + + const Slot = struct { + active: bool, + /// Set by `publish` when the ring is full. Never cleared while the + /// subscriber lives: the stream it belongs to is over. + overflowed: bool, + head: u32, + len: u32, + event: std.Io.Event, + ring: [ring_capacity]Entry, + }; + + /// Initializes in place. The rings are close to a megabyte, which a + /// by-value `init` would copy through the caller's frame. + /// + /// The ring storage stays undefined: `len` says which slots hold entries. + pub fn init(self: *Hub) void { + self.mutex = .init; + for (&self.slots) |*slot| { + slot.active = false; + slot.overflowed = false; + slot.head = 0; + slot.len = 0; + slot.event = .unset; + } + } + + /// Claims a slot, or null when all 32 are taken. + /// + /// `lockUncancelable` throughout this file: `publish`'s caller is + /// `Handler.handle`, which has no error union to carry `error.Canceled` + /// out of (the same reasoning as `clients.Tracker.track`), and the rest of + /// the surface shares the mutex with it. + pub fn subscribe(self: *Hub, io: std.Io) ?SubscriberId { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + for (&self.slots, 0..) |*slot, index| { + if (slot.active) continue; + slot.active = true; + slot.overflowed = false; + slot.head = 0; + slot.len = 0; + slot.event = .unset; + return @enumFromInt(index); + } + return null; + } + + /// Releases the slot. The caller must not be waiting on it. + pub fn unsubscribe(self: *Hub, io: std.Io, id: SubscriberId) void { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + const slot = self.slotOf(id); + slot.active = false; + slot.overflowed = false; + slot.len = 0; + slot.head = 0; + } + + /// Copies `entry` into every live ring and wakes its subscriber. Called + /// once per logged query. + pub fn publish(self: *Hub, io: std.Io, entry: Entry) void { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + for (&self.slots) |*slot| { + if (!slot.active or slot.overflowed) continue; + if (slot.len == ring_capacity) { + slot.overflowed = true; + } else { + slot.ring[(slot.head + slot.len) % ring_capacity] = entry; + slot.len += 1; + } + slot.event.set(io); + } + } + + /// The oldest entry this subscriber has not seen, or null when its ring is + /// empty. Check `overflowed` first: entries that predate the overflow are + /// still readable, but the stream must end once they run out. + pub fn next(self: *Hub, io: std.Io, id: SubscriberId) ?Entry { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + const slot = self.slotOf(id); + if (slot.len == 0) return null; + const entry = slot.ring[slot.head]; + slot.head = (slot.head + 1) % ring_capacity; + slot.len -= 1; + return entry; + } + + /// True once this subscriber missed an entry. The subscriber task ends the + /// response when it sees this. + pub fn overflowed(self: *Hub, io: std.Io, id: SubscriberId) bool { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + return self.slotOf(id).overflowed; + } + + /// Blocks until something is ready for this subscriber or `timeout` + /// passes; `.timeout` is the heartbeat's cue. + /// + /// The event is reset under the mutex and only while the ring is empty, so + /// a `publish` that lands between the check and the wait sets the event + /// again and the wait returns at once. Only the owning subscriber task + /// calls this, which is what `Event.reset` requires (`Io.zig:1866`). + /// + /// A spurious futex wakeup reports `.timeout` (`Io.zig:1824`): the caller + /// sends one heartbeat it did not strictly owe. + pub fn wait( + self: *Hub, + io: std.Io, + id: SubscriberId, + timeout: std.Io.Clock.Duration, + ) std.Io.Cancelable!Wake { + self.mutex.lockUncancelable(io); + const slot = self.slotOf(id); + if (slot.len > 0 or slot.overflowed) { + self.mutex.unlock(io); + return .ready; + } + slot.event.reset(); + self.mutex.unlock(io); + + slot.event.waitTimeout(io, .{ .duration = timeout }) catch |err| switch (err) { + error.Timeout => return .timeout, + error.Canceled => |e| return e, + }; + return .ready; + } + + fn slotOf(self: *Hub, id: SubscriberId) *Slot { + const slot = &self.slots[@intFromEnum(id)]; + std.debug.assert(slot.active); + return slot; + } +}; + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +fn sampleEntry(timestamp: i64, domain: []const u8) Entry { + return .init(.{ + .timestamp = timestamp, + .domain = domain, + .client_ip = "192.0.2.10", + .qtype = 1, + }); +} + +fn newHub(gpa: std.mem.Allocator) !*Hub { + const hub = try gpa.create(Hub); + hub.init(); + return hub; +} + +test "a subscriber reads what was published, oldest first" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const hub = try newHub(testing.allocator); + defer testing.allocator.destroy(hub); + + const id = hub.subscribe(io).?; + defer hub.unsubscribe(io, id); + + hub.publish(io, sampleEntry(1, "first.example")); + hub.publish(io, sampleEntry(2, "second.example")); + + try testing.expectEqualStrings("first.example", hub.next(io, id).?.domain()); + try testing.expectEqualStrings("second.example", hub.next(io, id).?.domain()); + try testing.expect(hub.next(io, id) == null); + try testing.expect(!hub.overflowed(io, id)); +} + +test "an entry published before a subscription is not delivered" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const hub = try newHub(testing.allocator); + defer testing.allocator.destroy(hub); + + hub.publish(io, sampleEntry(1, "early.example")); + + const id = hub.subscribe(io).?; + defer hub.unsubscribe(io, id); + try testing.expect(hub.next(io, id) == null); +} + +test "every live subscriber receives its own copy" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const hub = try newHub(testing.allocator); + defer testing.allocator.destroy(hub); + + const first = hub.subscribe(io).?; + const second = hub.subscribe(io).?; + defer hub.unsubscribe(io, first); + defer hub.unsubscribe(io, second); + + hub.publish(io, sampleEntry(7, "shared.example")); + + try testing.expectEqualStrings("shared.example", hub.next(io, first).?.domain()); + try testing.expectEqualStrings("shared.example", hub.next(io, second).?.domain()); +} + +test "the hub hands out every slot and then refuses" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const hub = try newHub(testing.allocator); + defer testing.allocator.destroy(hub); + + var ids: [max_subscribers]SubscriberId = undefined; + for (&ids) |*id| id.* = hub.subscribe(io).?; + try testing.expect(hub.subscribe(io) == null); + + hub.unsubscribe(io, ids[3]); + const reused = hub.subscribe(io).?; + try testing.expectEqual(ids[3], reused); +} + +test "a full ring marks the subscriber overflowed and stops copying" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const hub = try newHub(testing.allocator); + defer testing.allocator.destroy(hub); + + const id = hub.subscribe(io).?; + defer hub.unsubscribe(io, id); + + for (0..ring_capacity) |i| hub.publish(io, sampleEntry(@intCast(i), "fill.example")); + try testing.expect(!hub.overflowed(io, id)); + + hub.publish(io, sampleEntry(999, "lost.example")); + try testing.expect(hub.overflowed(io, id)); + + // What the ring already held is still readable; the entry that overflowed + // it is not, and the flag stays set. + var drained: usize = 0; + while (hub.next(io, id)) |entry| : (drained += 1) { + try testing.expectEqualStrings("fill.example", entry.domain()); + } + try testing.expectEqual(@as(usize, ring_capacity), drained); + try testing.expect(hub.overflowed(io, id)); +} + +test "the ring wraps around its head" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const hub = try newHub(testing.allocator); + defer testing.allocator.destroy(hub); + + const id = hub.subscribe(io).?; + defer hub.unsubscribe(io, id); + + // Two and a half laps, consuming as we go: the head passes the end of the + // storage twice and no entry is lost. + for (0..ring_capacity * 2 + ring_capacity / 2) |i| { + var buf: [32]u8 = undefined; + const domain = try std.fmt.bufPrint(&buf, "d{d}.example", .{i}); + hub.publish(io, sampleEntry(@intCast(i), domain)); + + const got = hub.next(io, id).?; + try testing.expectEqualStrings(domain, got.domain()); + try testing.expectEqual(@as(i64, @intCast(i)), got.timestamp); + } + try testing.expect(!hub.overflowed(io, id)); +} + +test "wait returns as soon as an entry is waiting" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const hub = try newHub(testing.allocator); + defer testing.allocator.destroy(hub); + + const id = hub.subscribe(io).?; + defer hub.unsubscribe(io, id); + + const long: std.Io.Clock.Duration = .{ .raw = .fromSeconds(60), .clock = .awake }; + hub.publish(io, sampleEntry(1, "ready.example")); + try testing.expectEqual(Wake.ready, try hub.wait(io, id, long)); +} + +test "wait times out on an idle subscriber so the heartbeat can go out" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const hub = try newHub(testing.allocator); + defer testing.allocator.destroy(hub); + + const id = hub.subscribe(io).?; + defer hub.unsubscribe(io, id); + + const brief: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(20), .clock = .awake }; + try testing.expectEqual(Wake.timeout, try hub.wait(io, id, brief)); + try testing.expect(hub.next(io, id) == null); +} + +test "a publish wakes a waiting subscriber" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const hub = try newHub(testing.allocator); + defer testing.allocator.destroy(hub); + + const id = hub.subscribe(io).?; + defer hub.unsubscribe(io, id); + + const long: std.Io.Clock.Duration = .{ .raw = .fromSeconds(60), .clock = .awake }; + var future = try io.concurrent(Hub.wait, .{ hub, io, id, long }); + + hub.publish(io, sampleEntry(5, "late.example")); + + try testing.expectEqual(Wake.ready, try future.await(io)); + try testing.expectEqualStrings("late.example", hub.next(io, id).?.domain()); +} + +test "an overflow wakes a waiting subscriber" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const hub = try newHub(testing.allocator); + defer testing.allocator.destroy(hub); + + const id = hub.subscribe(io).?; + defer hub.unsubscribe(io, id); + + for (0..ring_capacity) |i| hub.publish(io, sampleEntry(@intCast(i), "fill.example")); + while (hub.next(io, id)) |_| {} + + // The ring is empty again but its head sits mid-storage; refill it and + // overflow, so the wake comes from the flag rather than from an entry. + for (0..ring_capacity) |i| hub.publish(io, sampleEntry(@intCast(i), "fill.example")); + + const long: std.Io.Clock.Duration = .{ .raw = .fromSeconds(60), .clock = .awake }; + var future = try io.concurrent(Hub.wait, .{ hub, io, id, long }); + hub.publish(io, sampleEntry(999, "lost.example")); + + try testing.expectEqual(Wake.ready, try future.await(io)); + try testing.expect(hub.overflowed(io, id)); +} + +test "publishing while subscribers come and go reaches only the live ones" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const hub = try newHub(testing.allocator); + defer testing.allocator.destroy(hub); + + const steady = hub.subscribe(io).?; + defer hub.unsubscribe(io, steady); + + var churner = try io.concurrent(churn, .{ hub, io }); + + var published: usize = 0; + while (published < 500) : (published += 1) { + hub.publish(io, sampleEntry(@intCast(published), "churn.example")); + // Keep the steady subscriber under its ring cap: this test is about + // the churn, not about overflow. + while (hub.next(io, steady)) |_| {} + } + churner.await(io); + try testing.expect(!hub.overflowed(io, steady)); + + // Every slot the churner used is free again. + var ids: [max_subscribers - 1]SubscriberId = undefined; + for (&ids) |*id| id.* = hub.subscribe(io).?; + for (ids) |id| hub.unsubscribe(io, id); +} + +fn churn(hub: *Hub, io: std.Io) void { + for (0..200) |i| { + const id = hub.subscribe(io) orelse continue; + if (i % 3 == 0) _ = hub.next(io, id); + hub.unsubscribe(io, id); + } +} diff --git a/src/web/static.zig b/src/web/static.zig new file mode 100644 index 0000000..b75a18c --- /dev/null +++ b/src/web/static.zig @@ -0,0 +1,447 @@ +//! Static asset serving (milestone-8 ruling 24). +//! +//! Production serves from `web_assets`, the module the build generates from +//! `-Dweb-dist`: bytes, content type and a strong ETag per file, plus a +//! `.gz` sibling entry where compressing at build time paid off. Serving +//! is a linear scan over a handful of immutable entries — no allocation, no +//! clock, no disk. +//! +//! `ETag`/`If-None-Match` is the whole caching story. There is no +//! `Last-Modified` and no `Date`: std has no RFC 1123 formatter, and a strong +//! content hash validates an embedded immutable asset strictly better than a +//! timestamp would. +//! +//! An unknown path outside `/api` answers with index.html, 200 — the SPA owns +//! client-side routes, and its router needs the shell to load on a deep link. +//! `.gz` entries are reachable only through content negotiation, never as +//! paths of their own; each is a representation of its base file, with its own +//! ETag so a `304` is always judged against the representation that would be +//! served. +//! +//! Dev mode (`nxdns run --web-dev `, wired by the CLI) serves from disk +//! with no cache headers, so a UI developer sees an edit on reload. + +const std = @import("std"); + +const assets = @import("web_assets"); +const http_util = @import("http_util.zig"); +const server = @import("server.zig"); + +const log = std.log.scoped(.web_static); + +pub const File = assets.File; + +/// What the build embedded. Entries are sorted by path and immutable. +pub const embedded: []const File = assets.files; + +pub const index_path = "/index.html"; + +/// A disk asset a dev-mode request may read. Matches the embed limit in +/// tools/gen_web_assets.zig. +pub const max_disk_asset_bytes = 64 * 1024 * 1024; + +pub const Selection = struct { + file: *const File, + /// True when `file` is the gzip sibling and the response must carry + /// `content-encoding: gzip`. + gzip: bool, +}; + +/// Resolves a raw request path against `files`: exact match, `/` → index, +/// gzip sibling when the client accepts it. Null means no asset claims the +/// path and the caller decides between the SPA fallback and a 404. +pub fn select(files: []const File, raw_path: []const u8, accept_encoding: []const u8) ?Selection { + const path = if (raw_path.len == 0 or std.mem.eql(u8, raw_path, "/")) index_path else raw_path; + // A `.gz` entry is a representation, not an address. + if (std.mem.endsWith(u8, path, ".gz")) return null; + + const file = find(files, path) orelse return null; + + if (acceptsGzip(accept_encoding)) { + var buf: [http_util.max_target_len + 3]u8 = undefined; + const sibling = std.fmt.bufPrint(&buf, "{s}.gz", .{path}) catch return .{ .file = file, .gzip = false }; + if (find(files, sibling)) |gz| return .{ .file = gz, .gzip = true }; + } + return .{ .file = file, .gzip = false }; +} + +fn find(files: []const File, path: []const u8) ?*const File { + for (files) |*file| { + if (std.mem.eql(u8, file.path, path)) return file; + } + return null; +} + +/// Whether `accept-encoding` admits gzip. Every comma-separated entry is +/// scanned; a `gzip` entry decides over `*`; `q=0` refuses; an entry whose +/// parameters fall outside the grammar is unusable and refuses. An empty +/// header (or one the connection budget dropped) reads as identity-only, +/// which degrades to the uncompressed entry. +pub fn acceptsGzip(header: []const u8) bool { + var gzip_entry: ?bool = null; + var wildcard_entry: ?bool = null; + var tokens = std.mem.splitScalar(u8, header, ','); + while (tokens.next()) |token| { + var parts = std.mem.splitScalar(u8, token, ';'); + const name = std.mem.trim(u8, parts.next().?, " \t"); + const is_gzip = std.ascii.eqlIgnoreCase(name, "gzip"); + if (!is_gzip and !std.mem.eql(u8, name, "*")) continue; + + // The grammar admits one parameter and it is the weight. + var acceptable = true; + var saw_weight = false; + while (parts.next()) |param| { + const trimmed = std.mem.trim(u8, param, " \t"); + if (saw_weight or !std.ascii.startsWithIgnoreCase(trimmed, "q=")) { + acceptable = false; + break; + } + saw_weight = true; + acceptable = qualityAccepts(trimmed[2..]); + } + if (is_gzip) gzip_entry = acceptable else wildcard_entry = acceptable; + } + return gzip_entry orelse wildcard_entry orelse false; +} + +/// A well-formed nonzero qvalue: `0` or `1`, optionally `.` and up to three +/// digits, never exceeding 1. Malformed reads as not acceptable. +fn qualityAccepts(value: []const u8) bool { + if (value.len == 0 or value.len > 5) return false; + if (value[0] != '0' and value[0] != '1') return false; + if (value.len > 1 and value[1] != '.') return false; + var nonzero = value[0] == '1'; + if (value.len > 2) for (value[2..]) |c| { + if (!std.ascii.isDigit(c)) return false; + if (value[0] == '1' and c != '0') return false; + if (c != '0') nonzero = true; + }; + return nonzero; +} + +/// Whether an `if-none-match` header names `etag` (which carries its quotes). +/// Weak validators compare by content: a `W/` prefix on the wire still matches, +/// because the bytes behind a content hash are the content. +pub fn etagMatches(header: []const u8, etag: []const u8) bool { + var tokens = std.mem.splitScalar(u8, header, ','); + while (tokens.next()) |token| { + var candidate = std.mem.trim(u8, token, " \t"); + if (std.mem.eql(u8, candidate, "*")) return true; + if (std.mem.startsWith(u8, candidate, "W/")) candidate = candidate[2..]; + if (std.mem.eql(u8, candidate, etag)) return true; + } + return false; +} + +/// The SPA fallback handler (ruling 24): every non-`/api` path no route +/// claimed. W9 wires it as `WebState.fallback`. +pub fn fallback( + state: *server.WebState, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void { + _ = state; + _ = io; + + if (request.method != .GET and request.method != .HEAD) + return http_util.respondError(request, .not_found, "not found"); + + const selection = select(embedded, request.raw_path, request.accept_encoding) orelse + select(embedded, index_path, request.accept_encoding) orelse + return http_util.respondError(request, .not_found, "not found"); + + return respondAsset(request, selection); +} + +fn respondAsset(request: *http_util.Request, selection: Selection) http_util.HandlerError!void { + const file = selection.file; + + if (etagMatches(request.if_none_match, file.etag)) { + return request.http.respond("", .{ + .status = .not_modified, + .extra_headers = &.{ + .{ .name = "etag", .value = file.etag }, + .{ .name = "vary", .value = "accept-encoding" }, + }, + }); + } + + var headers_buf: [3]std.http.Header = .{ + .{ .name = "etag", .value = file.etag }, + .{ .name = "vary", .value = "accept-encoding" }, + .{ .name = "content-encoding", .value = "gzip" }, + }; + const headers: []const std.http.Header = headers_buf[0..if (selection.gzip) 3 else 2]; + return http_util.respondBytes(request, .ok, file.bytes, file.content_type, headers); +} + +/// Joins decoded path segments back into a relative disk path, or null when +/// any segment could escape the root. Segments were split before percent +/// decoding, so a decoded segment may contain `/` — that and `..` are the two +/// traversal shapes, and both are refused rather than normalized. +pub fn diskRelativePath(buf: []u8, segments: []const []const u8) ?[]const u8 { + if (segments.len == 0) return index_path[1..]; + var writer: std.Io.Writer = .fixed(buf); + for (segments, 0..) |segment, index| { + if (std.mem.eql(u8, segment, "..") or std.mem.eql(u8, segment, ".")) return null; + if (std.mem.findScalar(u8, segment, '/') != null) return null; + if (std.mem.findScalar(u8, segment, '\\') != null) return null; + if (std.mem.findScalar(u8, segment, 0) != null) return null; + if (index != 0) writer.writeAll("/") catch return null; + writer.writeAll(segment) catch return null; + } + return writer.buffered(); +} + +/// Dev-mode disk serving for `--web-dev` (ruling 24). No cache headers: the +/// point of the flag is that an edit shows up on the next reload. The CLI +/// wiring (W9) closes over the directory and passes it here. +pub fn serveFromDisk( + root: []const u8, + io: std.Io, + request: *http_util.Request, +) http_util.HandlerError!void { + if (request.method != .GET and request.method != .HEAD) + return http_util.respondError(request, .not_found, "not found"); + + var path_buf: [http_util.max_target_len]u8 = undefined; + const relative = diskRelativePath(&path_buf, request.path.segments()) orelse + return http_util.respondError(request, .not_found, "not found"); + + var dir = std.Io.Dir.cwd().openDir(io, root, .{}) catch |err| { + log.warn("web-dev directory '{s}' is unreadable: {t}", .{ root, err }); + return http_util.respondError(request, .internal_server_error, "web-dev directory unavailable"); + }; + defer dir.close(io); + + if (readDiskFile(dir, io, request, relative)) |bytes| + return http_util.respondBytes(request, .ok, bytes, contentType(relative), &.{}); + + // SPA fallback, same rule as the embedded path. + const index = readDiskFile(dir, io, request, index_path[1..]) orelse + return http_util.respondError(request, .not_found, "not found"); + return http_util.respondBytes(request, .ok, index, contentType(index_path), &.{}); +} + +fn readDiskFile( + dir: std.Io.Dir, + io: std.Io, + request: *http_util.Request, + sub_path: []const u8, +) ?[]const u8 { + if (!resolvesUnderRoot(dir, io, sub_path)) return null; + return dir.readFileAlloc(io, sub_path, request.arena, .limited(max_disk_asset_bytes)) catch |err| { + switch (err) { + error.FileNotFound, error.IsDir => {}, + else => log.warn("web-dev read of '{s}' failed: {t}", .{ sub_path, err }), + } + return null; + }; +} + +/// The lexical checks in `diskRelativePath` cannot see a symlink inside the +/// tree pointing out of it, so the target's canonical path must sit under the +/// root's. Racy against a concurrent rename, which loopback operator tooling +/// tolerates; any failure to resolve reads as a 404. +fn resolvesUnderRoot(dir: std.Io.Dir, io: std.Io, sub_path: []const u8) bool { + var root_buf: [std.Io.Dir.max_path_bytes]u8 = undefined; + var target_buf: [std.Io.Dir.max_path_bytes]u8 = undefined; + const root_len = dir.realPath(io, &root_buf) catch return false; + const target_len = dir.realPathFile(io, sub_path, &target_buf) catch return false; + const root = root_buf[0..root_len]; + const target = target_buf[0..target_len]; + return target.len > root.len + 1 and + std.mem.startsWith(u8, target, root) and target[root.len] == '/'; +} + +/// Extension → MIME type for dev-mode disk serving. The embedded entries carry +/// the same mapping, stamped by tools/gen_web_assets.zig; a test below keeps +/// the two from drifting. +pub fn contentType(path: []const u8) []const u8 { + const map = [_]struct { ext: []const u8, mime: []const u8 }{ + .{ .ext = ".html", .mime = "text/html; charset=utf-8" }, + .{ .ext = ".js", .mime = "text/javascript" }, + .{ .ext = ".mjs", .mime = "text/javascript" }, + .{ .ext = ".css", .mime = "text/css" }, + .{ .ext = ".svg", .mime = "image/svg+xml" }, + .{ .ext = ".png", .mime = "image/png" }, + .{ .ext = ".ico", .mime = "image/x-icon" }, + .{ .ext = ".json", .mime = "application/json" }, + .{ .ext = ".map", .mime = "application/json" }, + .{ .ext = ".webmanifest", .mime = "application/manifest+json" }, + .{ .ext = ".txt", .mime = "text/plain; charset=utf-8" }, + .{ .ext = ".woff2", .mime = "font/woff2" }, + .{ .ext = ".woff", .mime = "font/woff" }, + .{ .ext = ".wasm", .mime = "application/wasm" }, + }; + for (map) |entry| { + if (std.mem.endsWith(u8, path, entry.ext)) return entry.mime; + } + return "application/octet-stream"; +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +const test_files = [_]File{ + .{ .path = "/index.html", .bytes = "", .content_type = "text/html; charset=utf-8", .etag = "\"aaaa\"" }, + .{ .path = "/index.html.gz", .bytes = "gz!", .content_type = "text/html; charset=utf-8", .etag = "\"bbbb\"" }, + .{ .path = "/app.css", .bytes = "body{}", .content_type = "text/css", .etag = "\"cccc\"" }, +}; + +test "an exact path selects its file and the root selects the index" { + const css = select(&test_files, "/app.css", "").?; + try testing.expectEqualStrings("/app.css", css.file.path); + try testing.expect(!css.gzip); + + try testing.expectEqualStrings("/index.html", select(&test_files, "/", "").?.file.path); + try testing.expectEqualStrings("/index.html", select(&test_files, "", "").?.file.path); + try testing.expect(select(&test_files, "/missing.js", "gzip") == null); +} + +test "a gzip sibling is chosen only when the client accepts gzip" { + const plain = select(&test_files, "/index.html", "").?; + try testing.expect(!plain.gzip); + try testing.expectEqualStrings("\"aaaa\"", plain.file.etag); + + const gz = select(&test_files, "/index.html", "gzip, br").?; + try testing.expect(gz.gzip); + try testing.expectEqualStrings("\"bbbb\"", gz.file.etag); + try testing.expectEqualStrings("text/html; charset=utf-8", gz.file.content_type); + + // No sibling: the css stays identity even for a gzip client. + try testing.expect(!select(&test_files, "/app.css", "gzip").?.gzip); +} + +test "a .gz path is not addressable directly" { + try testing.expect(select(&test_files, "/index.html.gz", "gzip") == null); +} + +test "accept-encoding parsing scans every entry per the grammar" { + const cases = [_]struct { header: []const u8, accepts: bool }{ + .{ .header = "gzip", .accepts = true }, + .{ .header = "GZIP", .accepts = true }, + .{ .header = "br, gzip;q=0.5", .accepts = true }, + .{ .header = " deflate , gzip ", .accepts = true }, + .{ .header = "*", .accepts = true }, + .{ .header = "*;q=0.5", .accepts = true }, + .{ .header = "gzip;q=0.001", .accepts = true }, + .{ .header = "gzip;q=1", .accepts = true }, + .{ .header = "gzip;q=1.000", .accepts = true }, + .{ .header = "gzip;Q=0.5", .accepts = true }, + .{ .header = "", .accepts = false }, + .{ .header = "br, deflate", .accepts = false }, + .{ .header = "gzip;q=0", .accepts = false }, + .{ .header = "gzip;q=0.000", .accepts = false }, + // A specific gzip entry decides over the wildcard, in either order. + .{ .header = "*;q=0, gzip", .accepts = true }, + .{ .header = "gzip, *;q=0", .accepts = true }, + .{ .header = "gzip;q=0, *", .accepts = false }, + .{ .header = "*, gzip;q=0", .accepts = false }, + .{ .header = "*;q=0", .accepts = false }, + // Malformed entries are unusable, never acceptable. + .{ .header = "gzip;q=invalid", .accepts = false }, + .{ .header = "gzip;q=", .accepts = false }, + .{ .header = "gzip;q=1.5", .accepts = false }, + .{ .header = "gzip;q=0.5000", .accepts = false }, + .{ .header = "gzip;q=0..5", .accepts = false }, + .{ .header = "gzip;level=9", .accepts = false }, + .{ .header = "gzip;q=0.5;q=1", .accepts = false }, + // A malformed gzip entry still decides over a usable wildcard. + .{ .header = "*, gzip;q=invalid", .accepts = false }, + }; + for (cases) |case| { + testing.expectEqual(case.accepts, acceptsGzip(case.header)) catch |err| { + std.debug.print("header: '{s}'\n", .{case.header}); + return err; + }; + } +} + +test "if-none-match matches exact, listed, weak and wildcard validators" { + try testing.expect(etagMatches("\"aaaa\"", "\"aaaa\"")); + try testing.expect(etagMatches("\"xxxx\", \"aaaa\"", "\"aaaa\"")); + try testing.expect(etagMatches("W/\"aaaa\"", "\"aaaa\"")); + try testing.expect(etagMatches("*", "\"aaaa\"")); + try testing.expect(!etagMatches("\"xxxx\"", "\"aaaa\"")); + try testing.expect(!etagMatches("", "\"aaaa\"")); + try testing.expect(!etagMatches("aaaa", "\"aaaa\"")); +} + +test "disk paths join segments and refuse every traversal shape" { + var buf: [256]u8 = undefined; + + const nested = diskRelativePath(&buf, &.{ "assets", "app.js" }).?; + try testing.expectEqualStrings("assets/app.js", nested); + + try testing.expectEqualStrings("index.html", diskRelativePath(&buf, &.{}).?); + + try testing.expect(diskRelativePath(&buf, &.{ "..", "secret" }) == null); + try testing.expect(diskRelativePath(&buf, &.{"."}) == null); + // `%2F` decodes inside a segment; a joined `/` must not appear. + try testing.expect(diskRelativePath(&buf, &.{"../etc"}) == null); + try testing.expect(diskRelativePath(&buf, &.{"a\\b"}) == null); + + var tiny: [4]u8 = undefined; + try testing.expect(diskRelativePath(&tiny, &.{"toolong.html"}) == null); +} + +test "dev-mode disk reads refuse a symlink that escapes the root" { + const io = testing.io; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + var root = try tmp.dir.createDirPathOpen(io, "root", .{}); + defer root.close(io); + + try root.writeFile(io, .{ .sub_path = "inside.txt", .data = "ok" }); + try tmp.dir.writeFile(io, .{ .sub_path = "outside.txt", .data = "secret" }); + try root.symLink(io, "../outside.txt", "escape.txt", .{}); + try root.symLink(io, "..", "updir", .{ .is_directory = true }); + + try testing.expect(resolvesUnderRoot(root, io, "inside.txt")); + try testing.expect(!resolvesUnderRoot(root, io, "escape.txt")); + // A symlinked directory escapes through an intermediate component, which + // no-follow on the final open would miss. + try testing.expect(!resolvesUnderRoot(root, io, "updir/outside.txt")); + try testing.expect(!resolvesUnderRoot(root, io, "missing.txt")); +} + +test "the placeholder dist is embedded with its gzip siblings" { + const index = find(embedded, index_path).?; + try testing.expectEqualStrings("text/html; charset=utf-8", index.content_type); + try testing.expect(std.mem.containsAtLeast(u8, index.bytes, 1, "nxdns")); + try testing.expect(std.mem.containsAtLeast(u8, index.bytes, 1, "/api/health")); + + const favicon = find(embedded, "/favicon.svg").?; + try testing.expectEqualStrings("image/svg+xml", favicon.content_type); + + const gz = select(embedded, index_path, "gzip").?; + try testing.expect(gz.gzip); + try testing.expect(gz.file.bytes.len < index.bytes.len); + // The gzip member header: build-time compression, not an accident. + try testing.expectEqual(@as(u8, 0x1f), gz.file.bytes[0]); + try testing.expectEqual(@as(u8, 0x8b), gz.file.bytes[1]); +} + +test "embedded entries agree with the dev-mode content type map" { + for (embedded) |file| { + const base = if (std.mem.endsWith(u8, file.path, ".gz")) + file.path[0 .. file.path.len - 3] + else + file.path; + try testing.expectEqualStrings(contentType(base), file.content_type); + } +} + +test "every embedded etag is a quoted 32-digit hash" { + for (embedded) |file| { + try testing.expectEqual(@as(usize, 34), file.etag.len); + try testing.expectEqual(@as(u8, '"'), file.etag[0]); + try testing.expectEqual(@as(u8, '"'), file.etag[33]); + for (file.etag[1..33]) |c| try testing.expect(std.ascii.isHex(c)); + } +} diff --git a/src/web/web_integration_test.zig b/src/web/web_integration_test.zig new file mode 100644 index 0000000..6beec9e --- /dev/null +++ b/src/web/web_integration_test.zig @@ -0,0 +1,1468 @@ +//! Milestone-8 W10: the web layer end to end, over loopback sockets. +//! +//! The heart of the file is the contract table (ruling 23): one entry per +//! route in `routes.zig`, carrying the request that exercises it, the status +//! it must answer, and the response shape that `std.json.parseFromSlice` must +//! accept with `.ignore_unknown_fields = false`. The auth and rate-limit +//! markers are asserted against the served table rather than re-typed, so the +//! contract and the router cannot disagree about policy. +//! +//! Every test drives the real stack: `server.Server` accepting on 127.0.0.1, +//! the shipped route table, real `Sessions`, a real `ApiLimiter`, a real +//! `Manager` reloading real snapshots out of an in-memory config database, +//! and a seeded in-memory query log. The only stand-ins are the upstream +//! pool's transport (never exchanged with) and the blocklist fetcher (never +//! fetched from — the one `POST /api/blocklists/update` in the walk runs +//! before any source row exists, so the suite stays hermetic). +//! +//! Compiled by every `zig build test`; skips at run time without +//! `-Dintegration`, like the other loopback suites. + +const std = @import("std"); +const build_options = @import("build_options"); +const http = std.http; +const net = std.Io.net; +const Allocator = std.mem.Allocator; + +const address = @import("../platform/address.zig"); +const api_limiter = @import("api_limiter.zig"); +const auth = @import("auth.zig"); +const clients_repo = @import("../storage/repositories/clients_repo.zig"); +const db = @import("../storage/db.zig"); +const dns_handler = @import("../server/handler.zig"); +const fetcher = @import("../filter/fetcher.zig"); +const groups_repo = @import("../storage/repositories/groups_repo.zig"); +const header = @import("../dns/header.zig"); +const http_util = @import("http_util.zig"); +const local_repo = @import("../storage/repositories/local_repo.zig"); +const local_tables_mod = @import("../server/local_tables.zig"); +const manager_mod = @import("../filter/manager.zig"); +const migrations = @import("../storage/migrations.zig"); +const name = @import("../dns/name.zig"); +const openapi = @import("openapi.zig"); +const packet = @import("../dns/packet.zig"); +const pause_mod = @import("../server/pause.zig"); +const pool_mod = @import("../upstream/pool.zig"); +const queries_repo = @import("../storage/repositories/queries_repo.zig"); +const querylog_schema = @import("../storage/querylog_schema.zig"); +const question = @import("../dns/question.zig"); +const router = @import("router.zig"); +const server = @import("server.zig"); +const sources_repo = @import("../storage/repositories/sources_repo.zig"); +const sse = @import("sse.zig"); +const static = @import("static.zig"); +const transport = @import("../upstream/transport.zig"); +const types = @import("../dns/types.zig"); +const upstreams_repo = @import("../storage/repositories/upstreams_repo.zig"); + +const handlers_blocklists = @import("handlers/blocklists.zig"); +const handlers_health = @import("handlers/health.zig"); +const handlers_live = @import("handlers/live.zig"); +const handlers_lookup = @import("handlers/lookup.zig"); +const handlers_pause = @import("handlers/pause.zig"); +const handlers_queries = @import("handlers/queries.zig"); +const handlers_stats = @import("handlers/stats.zig"); +const handlers_upstream_health = @import("handlers/upstream_health.zig"); +const handlers_version = @import("handlers/version.zig"); + +const testing = std.testing; + +/// Enough for every plain request/response round trip on loopback. +const default_budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake }; + +/// The SSE test must outwait one real 15 s heartbeat interval. +const sse_budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(40), .clock = .awake }; + +// --------------------------------------------------------------------------- +// bounded client runner (a server that never answers fails, never hangs) +// --------------------------------------------------------------------------- + +const Outcome = union(enum) { + work: anyerror!void, + expiry: std.Io.Cancelable!void, +}; + +fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void { + return duration.sleep(io); +} + +fn bounded( + io: std.Io, + budget: std.Io.Clock.Duration, + comptime f: anytype, + args: std.meta.ArgsTuple(@TypeOf(f)), +) !void { + var outcomes: [2]Outcome = undefined; + var race: std.Io.Select(Outcome) = .init(io, &outcomes); + defer race.cancelDiscard(); + + try race.concurrent(.work, f, args); + try race.concurrent(.expiry, expire, .{ io, budget }); + + switch (try race.await()) { + .work => |result| return result, + .expiry => |result| { + try result; + return error.TestTimedOut; + }, + } +} + +// --------------------------------------------------------------------------- +// a small HTTP client +// --------------------------------------------------------------------------- + +const Conn = struct { + stream: net.Stream, + reader: net.Stream.Reader, + writer: net.Stream.Writer, + read_buf: [16384]u8 = undefined, + write_buf: [4096]u8 = undefined, + /// Header lines are copied here because each `takeDelimiterInclusive` + /// invalidates the previous line's slice into the read buffer. + head_buf: [4096]u8 = undefined, + + fn connect(self: *Conn, io: std.Io, addr: net.IpAddress) !void { + self.stream = try addr.connect(io, .{ .mode = .stream }); + self.reader = self.stream.reader(io, &self.read_buf); + self.writer = self.stream.writer(io, &self.write_buf); + } + + fn close(self: *Conn, io: std.Io) void { + self.stream.close(io); + } + + fn send(self: *Conn, bytes: []const u8) !void { + try self.writer.interface.writeAll(bytes); + try self.writer.interface.flush(); + } + + /// One request with optional body and one optional extra header line + /// (without its trailing CRLF). + fn request( + self: *Conn, + method: []const u8, + target: []const u8, + extra_header: ?[]const u8, + body: ?[]const u8, + ) !void { + var buf: [2048]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try w.print("{s} {s} HTTP/1.1\r\nhost: t\r\n", .{ method, target }); + if (extra_header) |line| try w.print("{s}\r\n", .{line}); + if (body) |b| try w.print("content-length: {d}\r\n\r\n{s}", .{ b.len, b }); + if (body == null) try w.writeAll("\r\n"); + try self.send(w.buffered()); + } + + /// Reads the head only: status line and headers up to the blank line. + fn receiveHead(self: *Conn) !Response { + var head_len: usize = 0; + while (true) { + const raw = try self.reader.interface.takeDelimiterInclusive('\n'); + const line = std.mem.trimEnd(u8, raw, "\r\n"); + if (line.len == 0) break; + if (head_len + line.len + 1 > self.head_buf.len) return error.TestHeadTooLarge; + @memcpy(self.head_buf[head_len..][0..line.len], line); + head_len += line.len; + self.head_buf[head_len] = '\n'; + head_len += 1; + } + const head = self.head_buf[0..head_len]; + const status = try parseStatus(head); + return .{ .status = status, .head = head, .body = &.{} }; + } + + /// Reads one response: head, then exactly `content-length` bytes. A + /// missing content-length reads as an empty body (204 and 304 answers may + /// omit it). + fn receive(self: *Conn, out: []u8) !Response { + const response = try self.receiveHead(); + const length = contentLength(response.head) orelse return response; + if (length > out.len) return error.TestResponseTooLarge; + const body = out[0..length]; + try self.reader.interface.readSliceAll(body); + return .{ .status = response.status, .head = response.head, .body = body }; + } + + /// De-frames chunked transfer coding into `sink` until `needle` appears in + /// the accumulated payload. The SSE body writer is unbuffered, so one + /// frame arrives as several small chunks; only the de-framed text is a + /// reliable haystack. + fn readChunkedUntil(self: *Conn, sink: *std.ArrayList(u8), gpa: Allocator, needle: []const u8) !void { + while (std.mem.indexOf(u8, sink.items, needle) == null) { + const size_line = try self.reader.interface.takeDelimiterInclusive('\n'); + // A chunk's closing CRLF may arrive eagerly (its own empty line + // here) or lazily in front of the next size; both read as noise. + const trimmed = std.mem.trim(u8, size_line, "\r\n"); + if (trimmed.len == 0) continue; + const size = try std.fmt.parseInt(usize, trimmed, 16); + if (size == 0) return error.TestStreamEnded; + + var chunk_buf: [4096]u8 = undefined; + var remaining = size; + while (remaining != 0) { + const step = @min(remaining, chunk_buf.len); + try self.reader.interface.readSliceAll(chunk_buf[0..step]); + try sink.appendSlice(gpa, chunk_buf[0..step]); + remaining -= step; + } + } + } +}; + +const Response = struct { + status: u16, + /// Borrows the connection's head buffer; valid until the next receive. + head: []const u8, + body: []const u8, + + fn header(self: Response, header_name: []const u8) ?[]const u8 { + var lines = std.mem.splitScalar(u8, self.head, '\n'); + _ = lines.next(); + while (lines.next()) |line| { + const colon = std.mem.findScalar(u8, line, ':') orelse continue; + if (!std.ascii.eqlIgnoreCase(std.mem.trim(u8, line[0..colon], " "), header_name)) continue; + return std.mem.trim(u8, line[colon + 1 ..], " "); + } + return null; + } +}; + +fn parseStatus(head: []const u8) !u16 { + const first_space = std.mem.findScalar(u8, head, ' ') orelse return error.TestBadResponse; + const rest = head[first_space + 1 ..]; + const second_space = std.mem.findScalar(u8, rest, ' ') orelse rest.len; + return std.fmt.parseInt(u16, rest[0..second_space], 10) catch error.TestBadResponse; +} + +fn contentLength(head: []const u8) ?usize { + var lines = std.mem.splitScalar(u8, head, '\n'); + while (lines.next()) |line| { + const colon = std.mem.findScalar(u8, line, ':') orelse continue; + if (!std.ascii.eqlIgnoreCase(std.mem.trim(u8, line[0..colon], " "), "content-length")) continue; + return std.fmt.parseInt(usize, std.mem.trim(u8, line[colon + 1 ..], " "), 10) catch null; + } + return null; +} + +// --------------------------------------------------------------------------- +// the environment: the real web stack over in-memory databases +// --------------------------------------------------------------------------- + +const seeded_query_rows = 25; + +const EnvOptions = struct { + password_hash: []const u8 = "", + rate_per_min: u32 = 100_000, + localhost_exempt: bool = true, + sse_max_per_ip: u16 = 3, + fallback: ?router.HandlerFn = null, +}; + +/// Heap-allocated because `state` and the listener hold pointers into it. +const Env = struct { + gpa: Allocator, + threaded: std.Io.Threaded, + tmp: testing.TmpDir, + config_db: db.Db, + querylog_db: db.Db, + http_client: std.http.Client, + transfer_buf: [fetcher.min_transfer_buf]u8, + redirect_buf: [fetcher.redirect_buffer_len]u8, + fetch: fetcher.Fetcher, + mgr: manager_mod.Manager, + sessions: auth.Sessions, + limiter: api_limiter.ApiLimiter, + hub: *sse.Hub, + pauser: pause_mod.Pause, + tables: local_tables_mod.LocalTables, + pool_entries: [1]pool_mod.Entry, + pool: pool_mod.Pool, + state: server.WebState, + web: server.Server, + group: std.Io.Group, + addr: net.IpAddress, + + fn create(gpa: Allocator, options: EnvOptions) !*Env { + const self = try gpa.create(Env); + errdefer gpa.destroy(self); + + self.gpa = gpa; + self.threaded = .init(gpa, .{}); + errdefer self.threaded.deinit(); + const ioh = self.threaded.io(); + + self.tmp = testing.tmpDir(.{ .iterate = true }); + errdefer self.tmp.cleanup(); + + self.config_db = try db.Db.open(":memory:", .{ .mode = .memory }); + errdefer self.config_db.close(); + try db.applyPragmas(&self.config_db, .{}); + _ = try migrations.migrate(&self.config_db); + try seedConfig(&self.config_db); + + self.querylog_db = try db.Db.open(":memory:", .{ .mode = .memory }); + errdefer self.querylog_db.close(); + try self.querylog_db.exec(querylog_schema.ddl); + try seedQueryLog(&self.querylog_db); + + // Real fetcher wiring; nothing in this suite downloads (the one + // refreshAll in the contract walk runs with zero source rows). + self.http_client = .{ .allocator = gpa, .io = ioh }; + errdefer self.http_client.deinit(); + self.fetch = .{ + .http = &self.http_client, + .transfer_buf = &self.transfer_buf, + .redirect_buf = &self.redirect_buf, + }; + + self.mgr = try manager_mod.Manager.init( + gpa, + &self.config_db, + .{ .dir = self.tmp.dir }, + &self.fetch, + .{}, + default_budget, + ); + errdefer self.mgr.deinit(ioh); + try self.mgr.reload(ioh); + + self.sessions = .init(24); + self.limiter = try api_limiter.ApiLimiter.init(gpa, .{ + .rate_per_min = options.rate_per_min, + .localhost_exempt = options.localhost_exempt, + .sse_max_per_ip = options.sse_max_per_ip, + }); + errdefer self.limiter.deinit(); + + // ~900 KiB: never a stack local (W5 rule). + self.hub = try gpa.create(sse.Hub); + errdefer gpa.destroy(self.hub); + self.hub.init(); + + self.pauser = .{}; + self.tables = .empty; + + self.pool_entries = .{.{ + .endpoint = transport.Endpoint.parse("https://dns.example/dns-query") catch unreachable, + // Never exchanged with: the pool feeds /api/upstream/health only. + .client = .{ .ptr = undefined, .exchangeFn = undefined }, + .priority = 1, + .enabled = true, + .health = .init, + }}; + self.pool = .init(&self.pool_entries, .{}, .{ .raw = .fromMilliseconds(50), .clock = .awake }, 1); + + self.state = .{ + .gpa = gpa, + .web = .{ + .password_hash = options.password_hash, + .api_rate_limit_per_min = options.rate_per_min, + .api_localhost_exempt = options.localhost_exempt, + .sse_max_connections_per_ip = options.sse_max_per_ip, + }, + .live_hash = .init(options.password_hash), + .pause = &self.pauser, + .manager = &self.mgr, + .pool = &self.pool, + .local_tables = &self.tables, + .sessions = &self.sessions, + .limiter = &self.limiter, + .hub = self.hub, + .config_db = &self.config_db, + .querylog_db = &self.querylog_db, + .version = "w10-test", + .started_unix = std.Io.Clock.real.now(ioh).toSeconds(), + .fallback = options.fallback, + .reload_fn = realReload, + }; + + const listen_address: net.IpAddress = try .parse("127.0.0.1", 0); + self.web = try server.Server.listen(gpa, ioh, listen_address, &self.state, .{ .max_connections = 8 }); + self.addr = self.web.boundAddress(); + + self.group = .init; + try self.group.concurrent(ioh, server.Server.serve, .{ &self.web, ioh }); + + return self; + } + + fn destroy(self: *Env) void { + const gpa = self.gpa; + const ioh = self.threaded.io(); + + self.web.deinit(gpa, ioh); + self.group.await(ioh) catch |err| switch (err) { + error.Canceled => unreachable, + }; + + self.state.live_hash.deinit(gpa); + self.tables.deinit(gpa); + gpa.destroy(self.hub); + self.limiter.deinit(); + self.mgr.deinit(ioh); + self.http_client.deinit(); + self.querylog_db.close(); + self.config_db.close(); + self.tmp.cleanup(); + self.threaded.deinit(); + gpa.destroy(self); + } + + fn io(self: *Env) std.Io { + return self.threaded.io(); + } + + /// The generation of the published snapshot. + fn generation(self: *Env) !u64 { + const handle = self.mgr.acquire(self.io()) orelse return error.TestNoSnapshot; + defer handle.release(self.io()); + return handle.snapshot.generation; + } +}; + +/// Ruling 12's seam, wired to the real manager the way app.zig wires it. +fn realReload(state: *server.WebState, io: std.Io) anyerror!void { + const mgr = state.manager orelse return; + try mgr.reload(io); +} + +/// One upstream (the settings PUT validates the whole stored config, which +/// insists on one) and one client row (clients have no POST, ruling 9). +fn seedConfig(database: *db.Db) !void { + try database.exec( + \\INSERT INTO upstreams (url, priority, enabled, tls_name) + \\VALUES ('https://dns.example/dns-query', 100, 1, '') + ); + try database.exec( + \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen) + \\VALUES ('192.168.1.50', 'laptop', 1, 0, 1700000000, 1700000000) + ); +} + +fn seedQueryLog(database: *db.Db) !void { + var writer = try queries_repo.BatchWriter.init(database); + defer writer.deinit(); + + var domain_buf: [32]u8 = undefined; + var index: usize = 0; + while (index < seeded_query_rows) : (index += 1) { + const domain = std.fmt.bufPrint(&domain_buf, "d{d}.example", .{index}) catch unreachable; + const blocked = index % 5 == 0; + try writer.writeBatch(&.{.{ + .timestamp = 1_700_000_000 + @as(i64, @intCast(index)), + .domain = domain, + .client_ip = "192.0.2.10", + .qtype = 1, + .blocked = blocked, + .block_reason = if (blocked) "blocklist_domain" else null, + .response_time_us = 250, + .cache_hit = if (blocked) null else (index % 2 == 0), + .upstream = if (blocked) null else "https://dns.example/dns-query", + }}); + } +} + +// --------------------------------------------------------------------------- +// the contract table (ruling 23) +// --------------------------------------------------------------------------- + +fn jsonShape(comptime T: type) *const fn (Allocator, []const u8) anyerror!void { + return &struct { + fn check(arena: Allocator, bytes: []const u8) anyerror!void { + _ = try std.json.parseFromSliceLeaky(T, arena, bytes, .{ .ignore_unknown_fields = false }); + } + }.check; +} + +// Response shapes the handlers keep private are restated here; the public +// ones are referenced directly so they cannot drift. +const LoginView = struct { authenticated: bool, auth_required: bool }; +const LogoutView = struct { authenticated: bool }; +const StatusList = struct { sources: []const handlers_blocklists.StatusView }; +const GroupsList = struct { groups: []const groups_repo.GroupRow }; +const GroupEcho = struct { id: i64, name: []const u8, safe_search: bool }; +const SourceIds = struct { source_ids: []const i64 }; +const SourcesList = struct { blocklists: []const sources_repo.SourceRow }; +const SourceEcho = struct { id: i64, url: []const u8, name: []const u8, enabled: bool, is_suggested: bool }; +const RuleShape = struct { + id: i64, + group_id: i64, + group: []const u8, + pattern: []const u8, + kind: []const u8, + action: []const u8, + created_at: i64, +}; +const RulesList = struct { rules: []const RuleShape }; +const RuleEcho = struct { id: i64, group_id: i64, pattern: []const u8, kind: []const u8, action: []const u8 }; +const RecordShape = struct { id: i64, name: []const u8, rtype: []const u8, value: []const u8, ttl: u32 }; +const RecordsList = struct { local_records: []const RecordShape }; +const ZonesList = struct { forward_zones: []const local_repo.ForwardZoneRow }; +const ClientsList = struct { clients: []const clients_repo.ClientRow }; +const PrefixesList = struct { client_prefixes: []const clients_repo.ClientPrefixRow }; +const UpstreamsList = struct { upstreams: []const upstreams_repo.UpstreamRow }; +const UpstreamEcho = struct { + id: i64, + url: []const u8, + priority: i32, + enabled: bool, + tls_name: []const u8, + restart_required: bool, +}; + +const TlsEndpointView = struct { + enabled: bool, + bind: []const u8, + port: u16, + cert_path: []const u8, + key_path: []const u8, +}; + +/// `GET /api/settings` in full. Parsed strictly, so this also proves the +/// response never carries `web.password` or `web.password_hash` (ruling 16). +const SettingsView = struct { + settings: struct { + runtime: struct { io_backend: []const u8 }, + upstream: struct { connect_timeout_ms: u32, read_timeout_ms: u32, total_timeout_ms: u32 }, + dns: struct { + bind_ipv4: []const u8, + bind_ipv6: []const u8, + port: u16, + rate_limit: u32, + rate_window_seconds: u32, + }, + blocking: struct { response: []const u8, ttl: u32 }, + cache: struct { size: u32, negative_ttl_max: u32 }, + web: struct { + enabled: bool, + bind: []const u8, + port: u16, + session_ttl_hours: u16, + api_rate_limit_per_min: u32, + api_localhost_exempt: bool, + sse_max_connections_per_ip: u16, + auth_enabled: bool, + }, + doh_server: TlsEndpointView, + dot_server: TlsEndpointView, + edns: struct { ecs_mode: []const u8 }, + logging: struct { + level: []const u8, + retention_days: u16, + query_log_buffer_max: u32, + hide_domains: bool, + hide_client_ips: bool, + output: []const u8, + file_path: []const u8, + max_size_mb: u32, + max_files: u8, + }, + disk: struct { min_free_mb: u32, warn_free_mb: u32 }, + blocklist_update: struct { enabled: bool, interval_hours: u16 }, + }, + restart_required: []const []const u8, +}; + +const Contract = struct { + method: http.Method, + /// Must equal a `routes.zig` pattern; the coverage test enforces it. + pattern: []const u8, + auth: router.Auth, + rate_limit: router.RateLimit = .counted, + /// The concrete request target the walk sends. + target: []const u8, + body: ?[]const u8 = null, + status: u16, + kind: enum { json, raw, none, sse } = .json, + /// `.json`: the strict response shape. + check: ?*const fn (Allocator, []const u8) anyerror!void = null, + /// `.raw`: a substring the body must contain. + needle: []const u8 = "", +}; + +/// Execution order is the table order: `POST /api/blocklists/update` runs +/// while no source row exists (hermetic), reads of `{id}` routes follow the +/// create that made the row, and deletes come last for their resource. +const contract = [_]Contract{ + // Monitoring and contract. + .{ .method = .GET, .pattern = "/metrics", .auth = .open, .rate_limit = .exempt, .target = "/metrics", .status = 200, .kind = .raw, .needle = "nxdns_up 1" }, + .{ .method = .GET, .pattern = "/api/health", .auth = .open, .rate_limit = .exempt, .target = "/api/health", .status = 200, .check = jsonShape(handlers_health.Body) }, + .{ .method = .GET, .pattern = "/api/version", .auth = .open, .target = "/api/version", .status = 200, .check = jsonShape(handlers_version.Body) }, + .{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .target = "/api/openapi.yaml", .status = 200, .kind = .raw, .needle = "openapi: 3.0.3" }, + + // Authentication (auth is disabled in the walk's environment; the on/off + // matrix has its own test). + .{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200, .check = jsonShape(LoginView) }, + .{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .target = "/api/auth/logout", .status = 200, .check = jsonShape(LogoutView) }, + + // Refresh-all before any source row exists: nothing to fetch, 202 anyway. + .{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .target = "/api/blocklists/update", .status = 202, .check = jsonShape(StatusList) }, + + // Query log, stats, live stream, upstream health. + .{ .method = .GET, .pattern = "/api/queries", .auth = .session, .target = "/api/queries?limit=10", .status = 200, .check = jsonShape(handlers_queries.Page) }, + .{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse }, + .{ .method = .GET, .pattern = "/api/stats", .auth = .session, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) }, + .{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) }, + .{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .target = "/api/upstream/health", .status = 200, .check = jsonShape(handlers_upstream_health.Body) }, + + // Groups. The migrated schema seeds `default` as id 1; the POST creates + // id 2, which the delete at the end of the walk removes. + .{ .method = .GET, .pattern = "/api/groups", .auth = .session, .target = "/api/groups", .status = 200, .check = jsonShape(GroupsList) }, + .{ .method = .POST, .pattern = "/api/groups", .auth = .session, .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201, .check = jsonShape(GroupEcho) }, + .{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .status = 200, .check = jsonShape(groups_repo.GroupRow) }, + .{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200, .check = jsonShape(GroupEcho) }, + .{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .target = "/api/groups/1/sources", .status = 200, .check = jsonShape(SourceIds) }, + .{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .target = "/api/groups/1/sources", .body = "{\"source_ids\":[]}", .status = 200, .check = jsonShape(SourceIds) }, + + // Blocklist sources. The POST runs after the refresh above, so the created + // row's url is never fetched. + .{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .target = "/api/blocklists", .status = 200, .check = jsonShape(SourcesList) }, + .{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .target = "/api/blocklists", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads\"}", .status = 201, .check = jsonShape(SourceEcho) }, + .{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .status = 200, .check = jsonShape(sources_repo.SourceRow) }, + .{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200, .check = jsonShape(SourceEcho) }, + .{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .status = 204, .kind = .none }, + + // Rules. The lookup below wants the blocking rule still in place, so the + // rule's delete follows it. + .{ .method = .GET, .pattern = "/api/rules", .auth = .session, .target = "/api/rules", .status = 200, .check = jsonShape(RulesList) }, + .{ .method = .POST, .pattern = "/api/rules", .auth = .session, .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201, .check = jsonShape(RuleEcho) }, + .{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .status = 200, .check = jsonShape(RuleShape) }, + .{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 200, .check = jsonShape(RuleEcho) }, + .{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .target = "/api/lookup?domain=ads.example", .status = 200, .check = jsonShape(handlers_lookup.Body) }, + .{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .status = 204, .kind = .none }, + + // Local records. + .{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .target = "/api/local-records", .status = 200, .check = jsonShape(RecordsList) }, + .{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201, .check = jsonShape(RecordShape) }, + .{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .status = 200, .check = jsonShape(RecordShape) }, + .{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200, .check = jsonShape(RecordShape) }, + .{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .status = 204, .kind = .none }, + + // Forward zones. + .{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .target = "/api/forward-zones", .status = 200, .check = jsonShape(ZonesList) }, + .{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201, .check = jsonShape(local_repo.ForwardZoneRow) }, + .{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) }, + .{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) }, + .{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .status = 204, .kind = .none }, + + // Clients (row id 1 is seeded — clients have no POST, ruling 9). + .{ .method = .GET, .pattern = "/api/clients", .auth = .session, .target = "/api/clients", .status = 200, .check = jsonShape(ClientsList) }, + .{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .status = 200, .check = jsonShape(clients_repo.ClientRow) }, + .{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200, .check = jsonShape(clients_repo.ClientRow) }, + .{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .status = 204, .kind = .none }, + .{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .target = "/api/client-prefixes", .status = 200, .check = jsonShape(PrefixesList) }, + .{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200, .check = jsonShape(PrefixesList) }, + + // Upstreams. Row id 1 is seeded; the POST creates id 2, whose delete + // cannot collide with the last-enabled-upstream guard. + .{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .target = "/api/upstreams", .status = 200, .check = jsonShape(UpstreamsList) }, + .{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201, .check = jsonShape(UpstreamEcho) }, + .{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/1", .status = 200, .check = jsonShape(upstreams_repo.UpstreamRow) }, + .{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200, .check = jsonShape(UpstreamEcho) }, + .{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/2", .status = 204, .kind = .none }, + + // Pause and settings. The pause POST leaves filtering running; the + // settings PUT is a real change, echoed by the same response shape. + .{ .method = .GET, .pattern = "/api/pause", .auth = .session, .target = "/api/pause", .status = 200, .check = jsonShape(handlers_pause.View) }, + .{ .method = .POST, .pattern = "/api/pause", .auth = .session, .target = "/api/pause", .body = "{\"paused\":false}", .status = 200, .check = jsonShape(handlers_pause.View) }, + .{ .method = .GET, .pattern = "/api/settings", .auth = .session, .target = "/api/settings", .status = 200, .check = jsonShape(SettingsView) }, + .{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200, .check = jsonShape(SettingsView) }, + + // The walk's last delete returns the groups table to its seeded shape. + .{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .status = 204, .kind = .none }, +}; + +// Drift guard: the contract table covers the served route table exactly — +// every entry matches one route, no route is missed, and the policy columns +// agree with the table the router dispatches from (not a re-typed copy). +// Pure bookkeeping, so it runs in every suite. +test "the contract table covers every served route with the served policy" { + var covered = [_]bool{false} ** 64; + try testing.expect(router.routes.len <= covered.len); + try testing.expectEqual(router.routes.len, contract.len); + + for (contract) |entry| { + var found = false; + for (router.routes, 0..) |route, index| { + if (route.method != entry.method) continue; + if (!std.mem.eql(u8, route.pattern, entry.pattern)) continue; + try testing.expect(!covered[index]); + covered[index] = true; + try testing.expectEqual(route.auth, entry.auth); + try testing.expectEqual(route.rate_limit, entry.rate_limit); + found = true; + break; + } + if (!found) { + std.debug.print("contract entry has no route: {t} {s}\n", .{ entry.method, entry.pattern }); + return error.TestUnexpectedResult; + } + } + for (covered[0..router.routes.len], 0..) |seen, index| { + if (!seen) { + std.debug.print("route has no contract entry: {s}\n", .{router.routes[index].pattern}); + return error.TestUnexpectedResult; + } + } +} + +fn contractWalk(io: std.Io, env: *Env) anyerror!void { + var arena_state: std.heap.ArenaAllocator = .init(env.gpa); + defer arena_state.deinit(); + + var body_buf: [128 * 1024]u8 = undefined; + for (contract, 0..) |entry, index| { + _ = arena_state.reset(.retain_capacity); + const arena = arena_state.allocator(); + + var conn: Conn = undefined; + try conn.connect(io, env.addr); + defer conn.close(io); + + try conn.request(@tagName(entry.method), entry.target, null, entry.body); + + if (entry.kind == .sse) { + const head = try conn.receiveHead(); + try testing.expectEqual(entry.status, head.status); + try testing.expectEqualStrings("text/event-stream", head.header("content-type").?); + var seen: std.ArrayList(u8) = .empty; + defer seen.deinit(env.gpa); + try conn.readChunkedUntil(&seen, env.gpa, "retry: 3000"); + continue; + } + + const response = conn.receive(&body_buf) catch |err| { + std.debug.print("contract[{d}] {t} {s}: no response ({t})\n", .{ index, entry.method, entry.target, err }); + return err; + }; + if (response.status != entry.status) { + std.debug.print( + "contract[{d}] {t} {s}: expected {d}, got {d} body {s}\n", + .{ index, entry.method, entry.target, entry.status, response.status, response.body }, + ); + return error.TestUnexpectedResult; + } + + switch (entry.kind) { + .json => { + const check = entry.check orelse return error.TestBadContractEntry; + check(arena, response.body) catch |err| { + std.debug.print( + "contract[{d}] {t} {s}: shape rejected ({t}) body {s}\n", + .{ index, entry.method, entry.target, err, response.body }, + ); + return err; + }; + }, + .raw => try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, entry.needle)), + .none => try testing.expectEqual(@as(usize, 0), response.body.len), + .sse => unreachable, + } + } +} + +test "W10 contract: every route answers its documented status and shape" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var env = try Env.create(gpa, .{}); + defer env.destroy(); + + try bounded(env.io(), default_budget, contractWalk, .{ env.io(), env }); +} + +// --------------------------------------------------------------------------- +// auth on/off matrix (rulings 17, 18) +// --------------------------------------------------------------------------- + +const test_password = "correct horse battery staple"; + +fn authMatrix(io: std.Io, env: *Env) anyerror!void { + var body_buf: [4096]u8 = undefined; + + var conn: Conn = undefined; + try conn.connect(io, env.addr); + defer conn.close(io); + + // Without a session: session routes are 401, ruling 18's open set is not. + try conn.request("GET", "/api/groups", null, null); + var response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 401), response.status); + try testing.expectEqualStrings("{\"error\":\"authentication required\"}", response.body); + + try conn.request("GET", "/api/health", null, null); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + + // A wrong password is 401 and mints nothing. + try conn.request("POST", "/api/auth/login", null, "{\"password\":\"wrong\"}"); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 401), response.status); + try testing.expectEqual(@as(?[]const u8, null), response.header("set-cookie")); + + // The right password sets the session cookie with ruling 17's attributes. + try conn.request("POST", "/api/auth/login", null, "{\"password\":\"" ++ test_password ++ "\"}"); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + const set_cookie = response.header("set-cookie") orelse return error.TestNoCookie; + try testing.expect(std.mem.containsAtLeast(u8, set_cookie, 1, "HttpOnly")); + try testing.expect(std.mem.containsAtLeast(u8, set_cookie, 1, "SameSite=Lax")); + try testing.expect(!std.mem.containsAtLeast(u8, set_cookie, 1, "Secure")); + + const cookie_end = std.mem.findScalar(u8, set_cookie, ';') orelse set_cookie.len; + var cookie_buf: [256]u8 = undefined; + const cookie_line = try std.fmt.bufPrint(&cookie_buf, "cookie: {s}", .{set_cookie[0..cookie_end]}); + + // The cookie opens the session routes. + try conn.request("GET", "/api/groups", cookie_line, null); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + + // Logout deletes the cookie and closes the session. + try conn.request("POST", "/api/auth/logout", cookie_line, null); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + const deleted = response.header("set-cookie") orelse return error.TestNoCookie; + try testing.expect(std.mem.containsAtLeast(u8, deleted, 1, "Max-Age=0")); + + try conn.request("GET", "/api/groups", cookie_line, null); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 401), response.status); +} + +test "W10 auth on: password-hashed environment enforces the session matrix" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + + // Hashed before the environment exists, so nothing mutates a `WebState` + // the server tasks are reading. + var hash_threaded: std.Io.Threaded = .init(gpa, .{}); + var hash_buf: [256]u8 = undefined; + const hash = std.crypto.pwhash.argon2.strHash(test_password, .{ + .allocator = gpa, + .params = .owasp_2id, + .mode = .argon2id, + .encoding = .phc, + }, &hash_buf, hash_threaded.io()) catch |err| { + hash_threaded.deinit(); + return err; + }; + hash_threaded.deinit(); + + var env = try Env.create(gpa, .{ .password_hash = hash }); + defer env.destroy(); + + try bounded(env.io(), default_budget, authMatrix, .{ env.io(), env }); +} + +fn authOff(io: std.Io, env: *Env) anyerror!void { + var body_buf: [4096]u8 = undefined; + var conn: Conn = undefined; + try conn.connect(io, env.addr); + defer conn.close(io); + + // No hash stored: every session route is open (ruling 17). + try conn.request("GET", "/api/groups", null, null); + var response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + + // Login still answers, reporting that no password is required, and mints + // no cookie. + try conn.request("POST", "/api/auth/login", null, "{\"password\":\"anything\"}"); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "\"auth_required\":false")); + try testing.expectEqual(@as(?[]const u8, null), response.header("set-cookie")); +} + +test "W10 auth off: an empty hash leaves every route open" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var env = try Env.create(gpa, .{}); + defer env.destroy(); + + try bounded(env.io(), default_budget, authOff, .{ env.io(), env }); +} + +// --------------------------------------------------------------------------- +// rate limiting (ruling 19) +// --------------------------------------------------------------------------- + +fn rateLimited(io: std.Io, env: *Env) anyerror!void { + // Large enough for the /metrics exposition at the end. + var body_buf: [64 * 1024]u8 = undefined; + var conn: Conn = undefined; + try conn.connect(io, env.addr); + defer conn.close(io); + + // Capacity 1: the first counted request spends the only token. + try conn.request("GET", "/api/version", null, null); + var response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + + try conn.request("GET", "/api/version", null, null); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 429), response.status); + try testing.expectEqualStrings("{\"error\":\"rate limited\"}", response.body); + const retry_after = response.header("retry-after") orelse return error.TestNoRetryAfter; + const seconds = try std.fmt.parseInt(u32, retry_after, 10); + try testing.expect(seconds >= 1); + + // The monitoring endpoints never see 429 (ruling 19). + try conn.request("GET", "/api/health", null, null); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + try conn.request("GET", "/metrics", null, null); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); +} + +test "W10 a drained bucket answers 429 with Retry-After and spares monitoring" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var env = try Env.create(gpa, .{ .rate_per_min = 1, .localhost_exempt = false }); + defer env.destroy(); + + try bounded(env.io(), default_budget, rateLimited, .{ env.io(), env }); +} + +// --------------------------------------------------------------------------- +// SSE (ruling 20): preamble, event frame, heartbeat, per-address cap +// --------------------------------------------------------------------------- + +fn sseStream(io: std.Io, env: *Env) anyerror!void { + var conn: Conn = undefined; + try conn.connect(io, env.addr); + defer conn.close(io); + + var seen: std.ArrayList(u8) = .empty; + defer seen.deinit(env.gpa); + errdefer std.debug.print("sse stream so far: {s}\n", .{seen.items}); + + try conn.request("GET", "/api/queries/live", null, null); + const head = try conn.receiveHead(); + try testing.expectEqual(@as(u16, 200), head.status); + try testing.expectEqualStrings("text/event-stream", head.header("content-type").?); + try conn.readChunkedUntil(&seen, env.gpa, "retry: 3000"); + + // One query on the hot path reaches the open stream as one frame. + env.hub.publish(io, .init(.{ + .timestamp = 1_700_000_000, + .domain = "live.example", + .client_ip = "192.0.2.99", + .qtype = 1, + .blocked = true, + .block_reason = "blocklist_domain", + })); + try conn.readChunkedUntil(&seen, env.gpa, "event: query"); + try conn.readChunkedUntil(&seen, env.gpa, "\"domain\":\"live.example\""); + try conn.readChunkedUntil(&seen, env.gpa, "\"blocked\":true"); + + // The cap is per address and the environment allows one stream: a second + // subscriber from the same address is refused while the first is open. + var second: Conn = undefined; + try second.connect(io, env.addr); + defer second.close(io); + var body_buf: [1024]u8 = undefined; + try second.request("GET", "/api/queries/live", null, null); + const refused = try second.receive(&body_buf); + try testing.expectEqual(@as(u16, 429), refused.status); + try testing.expect(std.mem.containsAtLeast(u8, refused.body, 1, "too many live streams")); + + // A quiet stream carries the heartbeat comment after the 15 s interval. + try conn.readChunkedUntil(&seen, env.gpa, ": ping"); +} + +test "W10 SSE: retry preamble, query frame, per-address cap and heartbeat" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var env = try Env.create(gpa, .{ .sse_max_per_ip = 1 }); + defer env.destroy(); + + try bounded(env.io(), sse_budget, sseStream, .{ env.io(), env }); +} + +// --------------------------------------------------------------------------- +// pagination walk (ruling 11) +// --------------------------------------------------------------------------- + +fn paginationWalk(io: std.Io, env: *Env) anyerror!void { + var arena_state: std.heap.ArenaAllocator = .init(env.gpa); + defer arena_state.deinit(); + + var conn: Conn = undefined; + try conn.connect(io, env.addr); + defer conn.close(io); + + var body_buf: [64 * 1024]u8 = undefined; + var target_buf: [64]u8 = undefined; + var before: ?i64 = null; + var total: usize = 0; + var last_id: i64 = std.math.maxInt(i64); + var pages: usize = 0; + + while (true) { + const target = if (before) |cursor| + try std.fmt.bufPrint(&target_buf, "/api/queries?limit=10&before={d}", .{cursor}) + else + try std.fmt.bufPrint(&target_buf, "/api/queries?limit=10", .{}); + + try conn.request("GET", target, null, null); + const response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + + const page = try std.json.parseFromSliceLeaky( + handlers_queries.Page, + arena_state.allocator(), + response.body, + .{ .ignore_unknown_fields = false }, + ); + + pages += 1; + total += page.queries.len; + for (page.queries) |row| { + try testing.expect(row.id < last_id); + last_id = row.id; + } + + before = page.next_before orelse break; + try testing.expect(pages < 10); + } + + // 25 seeded rows walk as 10, 10 and 5, with the cursor ending exactly + // after the third page. + try testing.expectEqual(@as(usize, seeded_query_rows), total); + try testing.expectEqual(@as(usize, 3), pages); +} + +test "W10 keyset pagination walks the seeded log exactly once, newest first" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var env = try Env.create(gpa, .{}); + defer env.destroy(); + + try bounded(env.io(), default_budget, paginationWalk, .{ env.io(), env }); +} + +// --------------------------------------------------------------------------- +// mutation → reload observed (ruling 12) +// --------------------------------------------------------------------------- + +fn mutationReloads(io: std.Io, env: *Env) anyerror!void { + // The environment's setup reload published generation 1. + try testing.expectEqual(@as(u64, 1), try env.generation()); + + var conn: Conn = undefined; + try conn.connect(io, env.addr); + defer conn.close(io); + + var body_buf: [4096]u8 = undefined; + try conn.request( + "POST", + "/api/rules", + null, + "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", + ); + var response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 201), response.status); + + // The write triggered a real `Manager.reload`: the published snapshot's + // generation bumped and the rule is live in the pipeline the lookup reads. + try testing.expectEqual(@as(u64, 2), try env.generation()); + + try conn.request("GET", "/api/lookup?domain=ads.example", null, null); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "\"blocked\":true")); + try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "\"reason\":\"rule_block_exact\"")); +} + +test "W10 a rule mutation reloads the snapshot and the change is live" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var env = try Env.create(gpa, .{}); + defer env.destroy(); + + try bounded(env.io(), default_budget, mutationReloads, .{ env.io(), env }); +} + +// --------------------------------------------------------------------------- +// pause via the API changes a real handler decision (ruling 15) +// --------------------------------------------------------------------------- + +/// A query for `domain`, RD set, one question, no OPT. +fn queryFor(buf: []u8, id: u16, domain: []const u8, qtype: types.Type) []const u8 { + var w: std.Io.Writer = .fixed(buf); + var encoded: [types.header_len]u8 = undefined; + header.encode(.{ + .id = id, + .flags = .{ + .rcode = .no_error, + .z = 0, + .ra = false, + .rd = true, + .tc = false, + .aa = false, + .opcode = .query, + .qr = false, + }, + .qdcount = 1, + .ancount = 0, + .nscount = 0, + .arcount = 0, + }, &encoded); + w.writeAll(&encoded) catch unreachable; + question.encode(.{ + .name = name.fromText(domain) catch unreachable, + .qtype = qtype, + .qclass = .in, + }, &w) catch unreachable; + return w.buffered(); +} + +/// Answers one A record for whatever it is asked; counts the calls so a test +/// can tell whether the filter let the query through. +const FakeUpstream = struct { + calls: std.atomic.Value(u64) = .init(0), + + fn exchangeFn( + ptr: *anyopaque, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) transport.ExchangeError![]u8 { + _ = io; + const self: *FakeUpstream = @ptrCast(@alignCast(ptr)); + _ = self.calls.fetchAdd(1, .monotonic); + + const request = packet.parse(query) catch return error.BadResponse; + const q = packet.firstQuestion(request) orelse return error.BadResponse; + + var b = packet.ResponseBuilder.init(response_buf, request.header, q) catch + return error.ResponseTooLarge; + b.addAnswer(q.name, .a, .in, 300, &.{ 93, 184, 216, 34 }) catch + return error.ResponseTooLarge; + return b.finish(); + } + + fn client(self: *FakeUpstream) transport.Client { + return .{ .ptr = self, .exchangeFn = exchangeFn }; + } +}; + +fn pauseAffectsHandler(io: std.Io, env: *Env, h: *dns_handler.Handler) anyerror!void { + var query_buf: [512]u8 = undefined; + var response_buf: [512]u8 = undefined; + var scratch: dns_handler.Scratch = undefined; + const from = address.NetAddress.fromIp(.{ .ip4 = .loopback(53000) }); + const query = queryFor(&query_buf, 0x2222, "ads.example", .a); + + // Filtering on: the rule blocks, the upstream is never asked. + const first = h.handle(io, .udp, from, query, &response_buf, &scratch); + try testing.expect(first == .reply); + try testing.expectEqual(@as(u64, 1), h.stats.blocked.load(.monotonic)); + + var conn: Conn = undefined; + try conn.connect(io, env.addr); + defer conn.close(io); + + var body_buf: [1024]u8 = undefined; + try conn.request("POST", "/api/pause", null, "{\"paused\":true,\"duration_seconds\":600}"); + var response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "\"paused\":true")); + + // The same query on the same handler now passes to the upstream: the API + // write and the DNS path share one `Pause`. + const second = h.handle(io, .udp, from, query, &response_buf, &scratch); + try testing.expect(second == .reply); + try testing.expectEqual(@as(u64, 1), h.stats.blocked.load(.monotonic)); + try testing.expectEqual(@as(u64, 1), h.stats.paused_queries.load(.monotonic)); + + // And unpausing through the API restores the block. + try conn.request("POST", "/api/pause", null, "{\"paused\":false}"); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + + const third = h.handle(io, .udp, from, query, &response_buf, &scratch); + try testing.expect(third == .reply); + try testing.expectEqual(@as(u64, 2), h.stats.blocked.load(.monotonic)); +} + +test "W10 pause via the API flips a real handler's blocking decision" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var env = try Env.create(gpa, .{}); + defer env.destroy(); + const io = env.io(); + + // A live blocking rule, through the same write path the UI uses. + try env.config_db.exec( + \\INSERT INTO rules (group_id, pattern, kind, action, created_at) + \\VALUES (1, 'ads.example', 'exact', 'block', 1700000000) + ); + try env.mgr.reload(io); + + var fake: FakeUpstream = .{}; + var h: dns_handler.Handler = .{ + .upstream = fake.client(), + .blocking = .{ .mode = .zero, .ttl = 5 }, + .forward_read_timeout = .{ .raw = .fromSeconds(2), .clock = .awake }, + .manager = &env.mgr, + .pause = &env.pauser, + }; + + try bounded(io, default_budget, pauseAffectsHandler, .{ io, env, &h }); + try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic)); +} + +// --------------------------------------------------------------------------- +// settings PUT round trip (ruling 16) +// --------------------------------------------------------------------------- + +fn settingsRoundTrip(io: std.Io, env: *Env) anyerror!void { + var arena_state: std.heap.ArenaAllocator = .init(env.gpa); + defer arena_state.deinit(); + + var conn: Conn = undefined; + try conn.connect(io, env.addr); + defer conn.close(io); + + var body_buf: [32 * 1024]u8 = undefined; + try conn.request("PUT", "/api/settings", null, "{\"dns\":{\"port\":5353},\"logging\":{\"level\":\"debug\"}}"); + var response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + + try conn.request("GET", "/api/settings", null, null); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + + const view = try std.json.parseFromSliceLeaky( + SettingsView, + arena_state.allocator(), + response.body, + .{ .ignore_unknown_fields = false }, + ); + try testing.expectEqual(@as(u16, 5353), view.settings.dns.port); + try testing.expectEqualStrings("debug", view.settings.logging.level); + try testing.expect(!view.settings.web.auth_enabled); + + // Every key is restart-required this milestone, the changed one included. + var found = false; + for (view.restart_required) |key| found = found or std.mem.eql(u8, key, "dns.port"); + try testing.expect(found); + + // A value the validator refuses changes nothing and answers 400. + try conn.request("PUT", "/api/settings", null, "{\"logging\":{\"level\":\"chatty\"}}"); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 400), response.status); +} + +test "W10 settings written through the API read back changed" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var env = try Env.create(gpa, .{}); + defer env.destroy(); + + try bounded(env.io(), default_budget, settingsRoundTrip, .{ env.io(), env }); +} + +// --------------------------------------------------------------------------- +// static assets: /, SPA fallback, ETag → 304 (ruling 24) +// --------------------------------------------------------------------------- + +fn staticFlow(io: std.Io, env: *Env) anyerror!void { + var conn: Conn = undefined; + try conn.connect(io, env.addr); + defer conn.close(io); + + var body_buf: [256 * 1024]u8 = undefined; + + try conn.request("GET", "/", null, null); + var response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + const content_type = response.header("content-type") orelse return error.TestNoContentType; + try testing.expect(std.mem.startsWith(u8, content_type, "text/html")); + const etag = response.header("etag") orelse return error.TestNoEtag; + try testing.expect(std.mem.startsWith(u8, etag, "\"")); + + var etag_buf: [256]u8 = undefined; + const etag_line = try std.fmt.bufPrint(&etag_buf, "if-none-match: {s}", .{etag}); + + // The same asset behind its own validator is 304 with no body. + try conn.request("GET", "/", etag_line, null); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 304), response.status); + try testing.expectEqual(@as(usize, 0), response.body.len); + + // An unknown non-/api path is the SPA's and serves index.html (200, not a + // redirect); an unknown /api path stays a JSON 404. + try conn.request("GET", "/some/client/route", null, null); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + + try conn.request("GET", "/api/nope", null, null); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 404), response.status); + try testing.expectEqualStrings("{\"error\":\"not found\"}", response.body); +} + +test "W10 the embedded assets serve /, fall back for the SPA and honor ETag" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var env = try Env.create(gpa, .{ .fallback = static.fallback }); + defer env.destroy(); + + try bounded(env.io(), default_budget, staticFlow, .{ env.io(), env }); +} + +// --------------------------------------------------------------------------- +// bodyless POST (W9's critical finding) +// --------------------------------------------------------------------------- + +fn bodylessPost(io: std.Io, env: *Env) anyerror!void { + var conn: Conn = undefined; + try conn.connect(io, env.addr); + defer conn.close(io); + + // Exactly `curl -X POST`: no content-length, no transfer-encoding. RFC + // 9110 gives this request an empty body; before the W9 fix it tripped the + // `discardBody` assert in std (http/Server.zig:631) and took the whole + // process down, DNS included. + try conn.send("POST /api/auth/logout HTTP/1.1\r\nhost: t\r\n\r\n"); + + var body_buf: [1024]u8 = undefined; + const response = conn.receive(&body_buf) catch |err| { + std.debug.print( + "bodyless-POST regression: the server sent no well-formed response ({t}); " ++ + "the discardBody fix in web/server.zig has not landed\n", + .{err}, + ); + return err; + }; + try testing.expectEqual(@as(u16, 200), response.status); + + // The connection survives and the next request is answered. + try conn.request("GET", "/api/version", null, null); + const second = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), second.status); +} + +test "W10 a POST with no body framing gets a response and keeps the connection" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var env = try Env.create(gpa, .{}); + defer env.destroy(); + + try bounded(env.io(), default_budget, bodylessPost, .{ env.io(), env }); +} + +// --------------------------------------------------------------------------- +// drift guards (ruling 23 a and b) +// --------------------------------------------------------------------------- + +// The route's path key must exist and its lowercase method key must sit +// inside that path's own block (from the key line to the next line at two +// spaces of indentation or less), so two documented paths cannot cover for +// each other's methods. Takes the document as a parameter so the negative +// test below can feed it a doctored copy. +fn yamlDocumentsRoute(yaml: []const u8, route: router.RouteInfo) error{ PathMissing, MethodMissing }!void { + var key_buf: [128]u8 = undefined; + const key = std.fmt.bufPrint(&key_buf, "\n {s}:\n", .{route.pattern}) catch + return error.PathMissing; + const key_at = std.mem.indexOf(u8, yaml, key) orelse return error.PathMissing; + + var method_buf: [16]u8 = undefined; + const method = std.fmt.bufPrint(&method_buf, " {s}:", .{@tagName(route.method)}) catch + return error.MethodMissing; + const needle = std.ascii.lowerString(method_buf[0..method.len], method); + + var lines = std.mem.splitScalar(u8, yaml[key_at + key.len ..], '\n'); + while (lines.next()) |line| { + if (line.len > 0 and !std.mem.startsWith(u8, line, " ")) break; + if (std.mem.eql(u8, line, needle)) return; + } + return error.MethodMissing; +} + +test "drift guard a: every served route appears under its own path in openapi.yaml" { + for (router.routes) |route| { + yamlDocumentsRoute(openapi.yaml, route) catch |err| { + std.debug.print( + "openapi.yaml drift for {s} {s}: {t}\n", + .{ @tagName(route.method), route.pattern, err }, + ); + return error.TestUnexpectedResult; + }; + } +} + +test "drift guard a bites: methods swapped between two documented paths fail the guard" { + const gpa = testing.allocator; + + // Swap the only operation of /metrics (get) with the only operation of + // /api/auth/logout (post). Both methods still appear somewhere in the + // document and the operation count is unchanged, so a whole-document + // substring check and guard b both stay green on this copy. + const half = try std.mem.replaceOwned( + u8, + gpa, + openapi.yaml, + "\n /metrics:\n get:", + "\n /metrics:\n post:", + ); + defer gpa.free(half); + const doctored = try std.mem.replaceOwned( + u8, + gpa, + half, + "\n /api/auth/logout:\n post:", + "\n /api/auth/logout:\n get:", + ); + defer gpa.free(doctored); + try testing.expect(std.mem.containsAtLeast(u8, doctored, 1, "\n /metrics:\n post:")); + try testing.expect(std.mem.containsAtLeast(u8, doctored, 1, "\n /api/auth/logout:\n get:")); + + var swapped_routes: usize = 0; + for (router.routes) |route| { + const swapped = (route.method == .GET and std.mem.eql(u8, route.pattern, "/metrics")) or + (route.method == .POST and std.mem.eql(u8, route.pattern, "/api/auth/logout")); + if (swapped) { + try testing.expectError(error.MethodMissing, yamlDocumentsRoute(doctored, route)); + swapped_routes += 1; + } else { + try yamlDocumentsRoute(doctored, route); + } + } + try testing.expectEqual(@as(usize, 2), swapped_routes); +} + +test "drift guard b: openapi.yaml documents exactly as many operations as the router serves" { + const yaml = openapi.yaml; + const paths_start = std.mem.indexOf(u8, yaml, "\npaths:\n") orelse return error.TestUnexpectedResult; + const paths_end = std.mem.indexOfPos(u8, yaml, paths_start, "\ncomponents:\n") orelse yaml.len; + const paths = yaml[paths_start..paths_end]; + + // Operations sit at exactly four spaces under their path key; nothing + // else in the paths section occupies that indent with these names. + var operations: usize = 0; + var lines = std.mem.splitScalar(u8, paths, '\n'); + while (lines.next()) |line| { + for ([_][]const u8{ " get:", " put:", " post:", " delete:" }) |needle| { + if (std.mem.eql(u8, line, needle)) operations += 1; + } + } + try testing.expectEqual(router.routes.len, operations); +} diff --git a/tools/gen_web_assets.zig b/tools/gen_web_assets.zig new file mode 100644 index 0000000..c47ba36 --- /dev/null +++ b/tools/gen_web_assets.zig @@ -0,0 +1,201 @@ +//! Build-time web asset indexer (milestone-8 ruling 24). +//! +//! `gen_web_assets ` reads a built web dist directory and +//! writes into ``: +//! +//! - `.gz` next to each asset worth compressing, unless the dist already +//! ships one (a Vite plugin may pre-compress). Compression happens here, at +//! build time, because `flate.Compress` needs a 64 KiB window per stream — +//! a cost the server must not pay per request for immutable content. +//! - `assets.zig`, the module index the server embeds: one entry per servable +//! path with its bytes, content type and a strong ETag. The build system +//! merges `` with a copy of the dist into one WriteFiles directory, +//! so every `@embedFile` path below resolves inside the module root. +//! +//! The entry list is sorted so the output is byte-identical across runs; the +//! build cache keys on it. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// A single asset never legitimately exceeds this; a bigger file is a build +/// mistake, not content to embed. +const max_asset_bytes = 64 * 1024 * 1024; + +/// Below this a gzip member's own header and footer eat the savings. +const min_compress_bytes = 128; + +const Asset = struct { + /// Request path, `/`-prefixed. + path: []const u8, + /// Path relative to the module root, for `@embedFile`. + file: []const u8, + content_type: []const u8, + etag: [32]u8, +}; + +pub fn main(init: std.process.Init) !void { + const arena = init.arena.allocator(); + const io = init.io; + const args = try init.minimal.args.toSlice(arena); + if (args.len != 3) std.process.fatal("usage: gen_web_assets ", .{}); + + var dist = std.Io.Dir.cwd().openDir(io, args[1], .{ .iterate = true }) catch |err| { + std.process.fatal("cannot open dist directory '{s}': {t}", .{ args[1], err }); + }; + defer dist.close(io); + var out = std.Io.Dir.cwd().openDir(io, args[2], .{}) catch |err| { + std.process.fatal("cannot open output directory '{s}': {t}", .{ args[2], err }); + }; + defer out.close(io); + + const names = try collectSorted(arena, io, dist); + + var assets: std.ArrayList(Asset) = .empty; + for (names) |name| { + if (std.mem.endsWith(u8, name, ".gz") and contains(names, name[0 .. name.len - 3])) { + // A pre-compressed sibling; indexed alongside its base file below. + continue; + } + + const bytes = dist.readFileAlloc(io, name, arena, .limited(max_asset_bytes)) catch |err| { + std.process.fatal("cannot read '{s}': {t}", .{ name, err }); + }; + try assets.append(arena, .{ + .path = try std.fmt.allocPrint(arena, "/{s}", .{name}), + .file = name, + .content_type = contentType(name), + .etag = etagOf(bytes), + }); + + const sibling = try std.fmt.allocPrint(arena, "{s}.gz", .{name}); + const gz = if (contains(names, sibling)) + dist.readFileAlloc(io, sibling, arena, .limited(max_asset_bytes)) catch |err| { + std.process.fatal("cannot read '{s}': {t}", .{ sibling, err }); + } + else + try compressWorthwhile(arena, io, out, sibling, bytes) orelse continue; + + try assets.append(arena, .{ + .path = try std.fmt.allocPrint(arena, "/{s}", .{sibling}), + .file = sibling, + .content_type = contentType(name), + .etag = etagOf(gz), + }); + } + + const index = try renderIndex(arena, assets.items); + try out.writeFile(io, .{ .sub_path = "assets.zig", .data = index }); +} + +fn collectSorted(arena: Allocator, io: std.Io, dist: std.Io.Dir) ![]const []const u8 { + var names: std.ArrayList([]const u8) = .empty; + var walker = try dist.walk(arena); + defer walker.deinit(); + while (try walker.next(io)) |entry| { + if (entry.kind != .file) continue; + for (entry.path) |c| { + if (!std.ascii.isAlphanumeric(c) and std.mem.findScalar(u8, "._-/", c) == null) { + std.process.fatal("asset name '{s}' has a character the index cannot carry", .{entry.path}); + } + } + try names.append(arena, try arena.dupe(u8, entry.path)); + } + std.mem.sort([]const u8, names.items, {}, lessThan); + return names.items; +} + +fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.order(u8, a, b) == .lt; +} + +fn contains(sorted: []const []const u8, name: []const u8) bool { + for (sorted) |candidate| { + if (std.mem.eql(u8, candidate, name)) return true; + } + return false; +} + +/// 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 +/// compressed (an image, a font) and the sibling would waste binary size. +fn compressWorthwhile( + arena: Allocator, + io: std.Io, + out: std.Io.Dir, + sub_path: []const u8, + bytes: []const u8, +) !?[]const u8 { + if (bytes.len < min_compress_bytes) return null; + + var sink: std.Io.Writer.Allocating = try .initCapacity(arena, @max(64, bytes.len / 2)); + const window = try arena.alloc(u8, std.compress.flate.max_window_len); + var compress = try std.compress.flate.Compress.init(&sink.writer, window, .gzip, .best); + try compress.writer.writeAll(bytes); + try compress.finish(); + + const gz = sink.written(); + if (gz.len >= bytes.len) return null; + + if (std.Io.Dir.path.dirname(sub_path)) |parent| try out.createDirPath(io, parent); + try out.writeFile(io, .{ .sub_path = sub_path, .data = gz }); + return gz; +} + +fn etagOf(bytes: []const u8) [32]u8 { + var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{}); + return std.fmt.bytesToHex(digest[0..16].*, .lower); +} + +fn contentType(name: []const u8) []const u8 { + const map = [_]struct { ext: []const u8, mime: []const u8 }{ + .{ .ext = ".html", .mime = "text/html; charset=utf-8" }, + .{ .ext = ".js", .mime = "text/javascript" }, + .{ .ext = ".mjs", .mime = "text/javascript" }, + .{ .ext = ".css", .mime = "text/css" }, + .{ .ext = ".svg", .mime = "image/svg+xml" }, + .{ .ext = ".png", .mime = "image/png" }, + .{ .ext = ".ico", .mime = "image/x-icon" }, + .{ .ext = ".json", .mime = "application/json" }, + .{ .ext = ".map", .mime = "application/json" }, + .{ .ext = ".webmanifest", .mime = "application/manifest+json" }, + .{ .ext = ".txt", .mime = "text/plain; charset=utf-8" }, + .{ .ext = ".woff2", .mime = "font/woff2" }, + .{ .ext = ".woff", .mime = "font/woff" }, + .{ .ext = ".wasm", .mime = "application/wasm" }, + }; + for (map) |entry| { + if (std.mem.endsWith(u8, name, entry.ext)) return entry.mime; + } + return "application/octet-stream"; +} + +fn renderIndex(arena: Allocator, assets: []const Asset) ![]const u8 { + var sink: std.Io.Writer.Allocating = try .initCapacity(arena, 4096); + const w = &sink.writer; + try w.writeAll( + \\//! Generated by tools/gen_web_assets.zig. Do not edit. + \\ + \\pub const File = struct { + \\ /// Request path, `/`-prefixed. + \\ path: []const u8, + \\ bytes: []const u8, + \\ content_type: []const u8, + \\ /// Strong validator, quotes included, hashed from `bytes` at build time. + \\ etag: []const u8, + \\}; + \\ + \\pub const files: []const File = &.{ + \\ + ); + for (assets) |asset| { + try w.print( + " .{{ .path = \"{s}\", .bytes = @embedFile(\"{s}\"), " ++ + ".content_type = \"{s}\", .etag = \"\\\"{s}\\\"\" }},\n", + .{ asset.path, asset.file, asset.content_type, asset.etag }, + ); + } + try w.writeAll("};\n"); + return sink.written(); +} diff --git a/web/dist-placeholder/favicon.svg b/web/dist-placeholder/favicon.svg new file mode 100644 index 0000000..e16e4d6 --- /dev/null +++ b/web/dist-placeholder/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/dist-placeholder/index.html b/web/dist-placeholder/index.html new file mode 100644 index 0000000..30997e8 --- /dev/null +++ b/web/dist-placeholder/index.html @@ -0,0 +1,59 @@ + + + + + +nxdns + + + + +
+

nxdns

+

DNS sinkhole — the admin interface ships in a later release.

+
+
Status
loading…
+
Upstreams
+
Disk
+
Version
+
+ +
+ + +