milestone 8: web server, rest api, sse, auth, metrics and static assets
This commit is contained in:
@@ -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 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 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 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();
|
const options = b.addOptions();
|
||||||
options.addOption(bool, "integration", integration);
|
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, "git_commit", git_commit);
|
||||||
options.addOption([]const u8, "zig_version_string", builtin.zig_version_string);
|
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);
|
b.installArtifact(exe);
|
||||||
|
|
||||||
const run = b.addRunArtifact(exe);
|
const run = b.addRunArtifact(exe);
|
||||||
@@ -53,6 +61,7 @@ pub fn build(b: *std.Build) void {
|
|||||||
tests.root_module.addAnonymousImport("test_fixtures", .{
|
tests.root_module.addAnonymousImport("test_fixtures", .{
|
||||||
.root_source_file = b.path("tests/fixtures/fixtures.zig"),
|
.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");
|
const test_step = b.step("test", "Run the test suite");
|
||||||
test_step.dependOn(&b.addRunArtifact(tests).step);
|
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| {
|
const query = std.Target.Query.parse(.{ .arch_os_abi = triple }) catch |err| {
|
||||||
std.debug.panic("invalid cross target '{s}': {t}", .{ triple, 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;
|
cross_exe.linkage = .static;
|
||||||
const install = b.addInstallArtifact(cross_exe, .{
|
const install = b.addInstallArtifact(cross_exe, .{
|
||||||
.dest_dir = .{ .override = .{ .custom = b.fmt("cross/{s}", .{triple}) } },
|
.dest_dir = .{ .override = .{ .custom = b.fmt("cross/{s}", .{triple}) } },
|
||||||
@@ -116,6 +125,7 @@ fn addExecutable(
|
|||||||
target: std.Build.ResolvedTarget,
|
target: std.Build.ResolvedTarget,
|
||||||
optimize: std.builtin.OptimizeMode,
|
optimize: std.builtin.OptimizeMode,
|
||||||
options: *std.Build.Step.Options,
|
options: *std.Build.Step.Options,
|
||||||
|
web_assets: std.Build.LazyPath,
|
||||||
) *std.Build.Step.Compile {
|
) *std.Build.Step.Compile {
|
||||||
const exe = b.addExecutable(.{
|
const exe = b.addExecutable(.{
|
||||||
.name = "nxdns",
|
.name = "nxdns",
|
||||||
@@ -127,11 +137,43 @@ fn addExecutable(
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
exe.root_module.addOptions("build_options", options);
|
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(sqliteLibrary(b, target, optimize));
|
||||||
exe.root_module.linkLibrary(mbedtlsLibrary(b, target, optimize));
|
exe.root_module.linkLibrary(mbedtlsLibrary(b, target, optimize));
|
||||||
return exe;
|
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).
|
/// SQLite 3.53.4 amalgamation (see build.zig.zon for the pinned URL and hash).
|
||||||
fn sqliteLibrary(
|
fn sqliteLibrary(
|
||||||
b: *std.Build,
|
b: *std.Build,
|
||||||
|
|||||||
@@ -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":"<message>"}`. Status codes: 400 validation, 401 unauthenticated,
|
||||||
|
404 missing, 405 wrong method (with Allow), 409 conflict (duplicate key), 413 body too
|
||||||
|
large, 429 rate limited (with Retry-After), 500 internal (generic message, detail to log
|
||||||
|
as warn), 503 over connection cap.
|
||||||
|
9. **Resource paths** (freezing PLAN:541's ellipses): collections `GET`+`POST` and items
|
||||||
|
`GET`+`PUT`+`DELETE` by numeric row id for: `/api/groups[/{id}]`,
|
||||||
|
`/api/blocklists[/{id}]` (the sources table), `/api/rules[/{id}]`,
|
||||||
|
`/api/local-records[/{id}]`, `/api/forward-zones[/{id}]`, `/api/upstreams[/{id}]`
|
||||||
|
(PLAN's list omitted upstreams; the Settings page must edit them; new resource, same
|
||||||
|
pattern). Group-source assignment: `PUT /api/groups/{id}/sources` with `{"source_ids":
|
||||||
|
[..]}` — idempotent full-set replace. Clients: `GET /api/clients` (ALL rows, materialized
|
||||||
|
included, each with `hand_edited`), `GET/PUT/DELETE /api/clients/{id}`; PUT sets
|
||||||
|
`hand_edited=1` and may change name/group; DELETE removes the row (a live client
|
||||||
|
re-materializes). Client prefixes: `GET/PUT /api/client-prefixes` as a whole-list
|
||||||
|
resource (tiny table, atomic replace). No POST for clients — creation is by DNS activity
|
||||||
|
or import (PLAN:540 gives clients no POST deliberately).
|
||||||
|
10. **Repo layer**: every list row the API serves carries its row id; each mutated resource
|
||||||
|
gains `getX(db, id)`, `updateX(db, id, item)`, `deleteX(db, id)` (strict: 0 rows touched
|
||||||
|
→ error.NotFound), written in the house repo idiom with prepared statements. Existing
|
||||||
|
import-path functions stay frozen.
|
||||||
|
11. **`GET /api/queries`**: keyset pagination `?limit` (default 100, max 1000) +
|
||||||
|
`?before=<row id>`, ordered id DESC; filters `domain=` (substring), `client=` (exact),
|
||||||
|
`blocked=` (bool), `since=`/`until=` (unix seconds). Response `{"queries":[...],
|
||||||
|
"next_before": <id>|null}`. Row fields: id, ts, domain, client_ip, qtype, blocked,
|
||||||
|
block_reason, response_time_us, cache_hit, upstream.
|
||||||
|
12. **Live vs restart**: mutations to rules, blocklists, groups, group-sources, clients and
|
||||||
|
client-prefixes call `Manager.reload(io)` and take effect live (the handler's next
|
||||||
|
`acquire` sees the new snapshot). Local records and forward zones ALSO apply live: a new
|
||||||
|
`src/server/local_tables.zig` RCU holder (acquire/release + swap, mirroring the manager's
|
||||||
|
pattern at small scale) owns `{records, zones}`; `Handler` reads through it instead of
|
||||||
|
`*const` fields; the local-records/forward-zones handlers rebuild and swap. Upstream
|
||||||
|
mutations and everything in `/api/settings` are restart-required. `POST
|
||||||
|
/api/blocklists/update` = `Manager.refreshAll` then reload, 202 with the status snapshot.
|
||||||
|
13. **Stats grammar**: `period=1h|24h|7d|30d` (else 400). `/api/stats` returns totals
|
||||||
|
{queries, blocked, cached, clients (distinct), avg_response_time_us} for the period.
|
||||||
|
`/api/stats/timeseries` buckets: 1h→60×1m, 24h→48×30m, 7d→168×1h, 30d→120×6h; UTC; each
|
||||||
|
bucket {ts, queries, blocked, cached}. SQL aggregates over query_log on the web task's
|
||||||
|
own read connection.
|
||||||
|
14. **`GET /api/lookup?domain=&group_id=`** (group optional, default group when absent):
|
||||||
|
normalizes, then reports the full pipeline view: `{domain, group_id, local_records:
|
||||||
|
bool, forward_zone: <zone>|null, blocked, reason, matched, source_url|null,
|
||||||
|
safe_search_rewrite: <target>|null}` via `snapshot.evaluate` + records/zones lookups.
|
||||||
|
Null snapshot → 503 `{"error":"no snapshot loaded"}`.
|
||||||
|
15. **Pause API**: `GET /api/pause` → `{"paused": bool, "until": <unix s>|null}` (null while
|
||||||
|
unpaused OR indefinite — disambiguated by `paused`). `POST /api/pause` body
|
||||||
|
`{"paused": true, "duration_seconds": <u32, optional>}` or `{"paused": false}`.
|
||||||
|
16. **`GET/PUT /api/settings`**: the typed config sections that live in settings rows (dns,
|
||||||
|
blocking, cache, edns, upstream, logging, disk, blocklist_update, safe_search flag home
|
||||||
|
— read model.toSettings for the exact key list), minus `web.password*` (never
|
||||||
|
serialized; a PUT carrying `web.password` re-hashes via the import path's argon2id
|
||||||
|
params and stores only the hash). GET marks every key `restart_required: true` except
|
||||||
|
none — ALL settings are restart-required this milestone (live behavior comes from the
|
||||||
|
resource endpoints and pause; a static table in the handler carries the flag so the UI
|
||||||
|
banner has transport). PUT validates the merged config through `validate.validate`
|
||||||
|
before writing any row; partial updates allowed.
|
||||||
|
17. **Auth**: enabled iff `web.password_hash != ""`. Login verifies with
|
||||||
|
`std.crypto.pwhash.argon2.strVerify(hash, password, .{ .allocator = gpa }, io)`
|
||||||
|
(argon2.zig:619 — PHC string carries its params). Sessions: in-memory fixed table of 32
|
||||||
|
(LRU evict), storing SHA-256 of a 32-byte `io.randomSecure` token; lookup compares
|
||||||
|
digests with `std.crypto.timing_safe.eql([32]u8, ...)` (slices not accepted —
|
||||||
|
timing_safe.zig:12). Cookie `nxdns_session=<url_safe_no_pad base64>`; `HttpOnly;
|
||||||
|
SameSite=Lax; Path=/`; `Secure` NOT set (nxdns serves plain HTTP; TLS termination is the
|
||||||
|
operator's proxy — documented). Expiry `web.session_ttl_hours`. Logout deletes the
|
||||||
|
session. A `PUT /api/settings` that changes `password_hash` clears every session.
|
||||||
|
18. **Auth exemptions**: `/api/health`, `/api/version`, `/metrics`, `/api/openapi.yaml`, and
|
||||||
|
the static assets are always unauthenticated (monitoring endpoints; the SPA shell must
|
||||||
|
load to show a login form). Everything else 401s without a valid session when auth is
|
||||||
|
enabled. `/api/auth/login` is necessarily exempt; failed logins count and are rate
|
||||||
|
limited like any request.
|
||||||
|
19. **API rate limiting**: token bucket per client IP (NOT the DNS fixed-window limiter —
|
||||||
|
m6 ruling 8 reserved the bucket shape for the API): capacity and refill
|
||||||
|
`web.api_rate_limit_per_min` per 60 s, 4096 tracked IPs (same fixed-table idiom as
|
||||||
|
rate_limiter/tracker). Localhost exemption: NEW config field `web.api_localhost_exempt:
|
||||||
|
bool = true` (PLAN §10 says "configurable" and §12.1 forgot the field; model + validate
|
||||||
|
+ settings key + export round trip — settings are kv rows, no migration needed). 429 +
|
||||||
|
`Retry-After: <s>`. SSE: connect consumes a token AND respects
|
||||||
|
`web.sse_max_connections_per_ip`; `/metrics` and `/api/health` are limiter-exempt
|
||||||
|
(Prometheus must never see 429).
|
||||||
|
20. **SSE**: `GET /api/queries/live`, `text/event-stream` via `respondStreaming(&.{}, ...)`
|
||||||
|
(EMPTY buffer — `BodyWriter.flush` does not flush the body writer's own buffer,
|
||||||
|
http.zig:780; empty buffer makes every write go straight to the chunked drain), flush
|
||||||
|
headers before the first event (test.zig:498 pattern). Fanout: milestone-6 ruling 4
|
||||||
|
concretized as `src/server/query_sink.zig`: `QuerySink { logger: *Logger, hub: ?*sse.Hub
|
||||||
|
}` with `log(io, entry)` = transform once, publish to the hub, enqueue to the logger.
|
||||||
|
Logger gains additive `transformed(entry) Entry` (pure) and `logTransformed(io, entry)`;
|
||||||
|
existing `log` = both, frozen behavior. `Handler.logger: ?*Logger` becomes `sink:
|
||||||
|
?*QuerySink` (mechanical rename at the two call sites + app wiring). Hub: fixed 32
|
||||||
|
subscriber slots, each a 64-entry ring + `std.Io.Event`; publish never blocks (full ring
|
||||||
|
→ disconnect that subscriber; SSE clients auto-reconnect). Wire format: `retry: 3000`
|
||||||
|
once, then `event: query` + `data: <JSON, same fields as /api/queries rows>` per entry;
|
||||||
|
heartbeat comment `: ping` every 15 s from the subscriber task (Event.waitTimeout).
|
||||||
|
Fanout precedes persistence (PLAN:455) — the sink publishes before enqueue.
|
||||||
|
21. **`/metrics`**: Prometheus text format 0.0.4, `nxdns_` prefix, `# HELP`/`# TYPE` lines.
|
||||||
|
Counters: every `Handler.Stats` field (17, individually, `nxdns_dns_<field>_total`),
|
||||||
|
logger (queries_dropped, rows_written, batches_gated), cache stats (6, under
|
||||||
|
`Handler.cache_mutex`), DNS limiter stats (under `limiter_mutex`), tracker stats,
|
||||||
|
retention stats, log-sink stats, `nxdns_blocklist_refreshes_gated_total`. Gauges:
|
||||||
|
cache len + memory bytes, disk free/db/log bytes, tracked DNS clients, pending tracker
|
||||||
|
clients, blocklist generation, `nxdns_up 1`. Per-upstream, labeled `{url="..."}`:
|
||||||
|
up (available), success_rate, consecutive_failures, total_successes/_failures (copy
|
||||||
|
`Pool.Snapshot` fields while holding the returned count — `last_error` is borrowed,
|
||||||
|
copy immediately). No timestamps. Auth-exempt, limiter-exempt.
|
||||||
|
22. **`GET /api/health`**: `{"status":"ok"|"degraded","disk":{"state":...,
|
||||||
|
"free_bytes":...,"db_bytes":...,"log_bytes":...,"sample_failures":...},
|
||||||
|
"upstreams":{"available":N,"total":M},"queries_dropped":N,"writer_failed":bool,
|
||||||
|
"refreshes_gated":N,"snapshot_generation":N|null}`. Degraded iff disk state != ok, or
|
||||||
|
zero upstreams available, or writer_failed. 200 either way (degraded is data, not an
|
||||||
|
HTTP failure).
|
||||||
|
23. **OpenAPI + contract tests**: hand-written `src/web/openapi.yaml`, `@embedFile`d and
|
||||||
|
served verbatim. No external validator (dependencies are liabilities): the contract IS a
|
||||||
|
Zig table in the integration test — every route with method, auth requirement, a seeded
|
||||||
|
request, expected status, and a response-shape struct that `std.json.parseFromSlice`
|
||||||
|
must accept with `.ignore_unknown_fields = false`. Two drift guards: (a) the router
|
||||||
|
exposes `pub const routes: []const RouteInfo`; a test asserts every entry's path+method
|
||||||
|
appears textually in openapi.yaml; (b) the table covers every `routes` entry (count
|
||||||
|
equality). CI gets one new job step running the existing `-Dintegration` suite (contract
|
||||||
|
tests live inside it) — no new tooling.
|
||||||
|
24. **Static assets**: build.zig gains `-Dweb-dist=<path>` (default `web/dist-placeholder/`,
|
||||||
|
committed, containing a real minimal `index.html` — current status via /api/health
|
||||||
|
fetch, links to /metrics; plus favicon). A build step copies the dist dir via
|
||||||
|
`b.addWriteFiles()` + generated `assets.zig` index (the fixtures anonymous-import
|
||||||
|
pattern, per research notes — @embedFile paths must live inside the module root),
|
||||||
|
exposing `pub const files: []const File{path, bytes, content_type, etag}`; etag =
|
||||||
|
comptime hash. Serving: exact path match, `/` → index.html, SPA fallback (unknown
|
||||||
|
non-/api path → index.html, 200 — TanStack Router needs it in M9), `ETag`/
|
||||||
|
`If-None-Match` → 304, `content-encoding: gzip` when a sibling `<name>.gz` exists in
|
||||||
|
the dist AND the request accepts it. No Date/Last-Modified headers (std has no RFC 1123
|
||||||
|
formatter; ETag is strictly better for immutable embedded content). Dev mode: CLI flag
|
||||||
|
`nxdns run --web-dev <dir>` serves from disk (no cache headers), bypassing the embed.
|
||||||
|
25. **Head-string trap**: `request.head.target` is invalidated by body reads
|
||||||
|
(Server.zig:594/230). The router copies target into a stack buffer before any body
|
||||||
|
read. Query strings: split on '?', `std.mem.splitScalar` for pairs,
|
||||||
|
`Uri.percentDecodeInPlace` per value (std has no query iterator; std.Uri does not parse
|
||||||
|
origin-form targets).
|
||||||
|
26. **Wiring**: one `web_server.serve` task in the app group, started last, canceled by the
|
||||||
|
same group.cancel. `WebState` struct (in web/server.zig) of borrowed pointers assembled
|
||||||
|
by app.serve: handler (stats + mutexes + cache/limiter), pause, tracker, manager, pool,
|
||||||
|
monitor, logger, retention stats, local_tables, sink/hub, config arena view (web cfg),
|
||||||
|
its OWN `config.db` + `querylog.db` connections (m7 ruling 21), version string, started
|
||||||
|
timestamp. Web DB connections open only when `web.enabled`.
|
||||||
|
27. **No new schema migration**: query_log already carries what /api/queries needs; the
|
||||||
|
read session verifies existing indexes and reports if a needed index is missing (then
|
||||||
|
the orchestrator rules on adding migration v3 — do not add one silently).
|
||||||
|
28. **Client disconnect**: stream writes surface `error.WriteFailed` with the real cause in
|
||||||
|
`stream_writer.err` (ConnectionResetByPeer / SocketUnconnected=EPIPE — MSG.NOSIGNAL is
|
||||||
|
set, Threaded.zig:13071, no SIGPIPE masking needed). A failed write ends the connection
|
||||||
|
task quietly (debug log at most): clients vanishing is normal.
|
||||||
|
29. **Input sanitation** (PLAN §19): every path/query/body input is length-capped and
|
||||||
|
validated before SQL (prepared statements everywhere, already house rule); domain
|
||||||
|
inputs run through matcher.normalize; no secrets ever logged (password, tokens, cookie
|
||||||
|
values — assert by review, and the login handler logs only the client IP and outcome).
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
Wave 1 (parallel): W1 query-log reads, W2 repo CRUD, W3 web core, W4 auth+limiter,
|
||||||
|
W5 sink+SSE hub+logger split.
|
||||||
|
Wave 2 (after W3, parallel where files allow): W6 read handlers + metrics, W7 mutation
|
||||||
|
handlers + local_tables + handler.zig sink/local_tables changes.
|
||||||
|
Wave 3: W8 static assets + build.zig + openapi.yaml + router registration of everything.
|
||||||
|
Wave 4: W9 app/cli wiring. Wave 5: W10 integration + contract tests + CI.
|
||||||
|
The orchestrator wires src/tests.zig per wave and rules on anything a session flags.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session W1: query-log read layer
|
||||||
|
|
||||||
|
Owns: `src/storage/repositories/queries_repo.zig` (additive; BatchWriter and friends frozen).
|
||||||
|
|
||||||
|
```zig
|
||||||
|
pub const QueryRow = struct { id: i64, ts: i64, domain: []const u8, client_ip: []const u8,
|
||||||
|
qtype: ?u16, blocked: bool, block_reason: []const u8, response_time_us: ?i64,
|
||||||
|
cache_hit: ?bool, upstream: []const u8 };
|
||||||
|
pub const QueryFilter = struct { limit: u32 = 100, before: ?i64 = null,
|
||||||
|
domain_substring: ?[]const u8 = null, client: ?[]const u8 = null,
|
||||||
|
blocked: ?bool = null, since: ?i64 = null, until: ?i64 = null };
|
||||||
|
/// Rows come back newest-first. Strings are arena-allocated.
|
||||||
|
pub fn selectQueries(database: *db.Db, arena: Allocator, filter: QueryFilter)
|
||||||
|
db.Error!std.ArrayList(QueryRow)
|
||||||
|
pub const StatsTotals = struct { queries: u64, blocked: u64, cached: u64,
|
||||||
|
distinct_clients: u64, avg_response_time_us: ?i64 };
|
||||||
|
pub fn statsTotals(database: *db.Db, since: i64, until: i64) db.Error!StatsTotals
|
||||||
|
pub const Bucket = struct { ts: i64, queries: u64, blocked: u64, cached: u64 };
|
||||||
|
/// Fills `out` with fixed-width buckets covering [since, until); returns the count.
|
||||||
|
pub fn timeseries(database: *db.Db, since: i64, bucket_seconds: u32, out: []Bucket)
|
||||||
|
db.Error!usize
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the real query_log schema first (querylog_schema.zig) — domain interning means a JOIN;
|
||||||
|
verify what indexes exist and REPORT (ruling 27) if `ts` or the join needs one that is
|
||||||
|
missing rather than adding a migration. Dynamic WHERE assembly must still use bound
|
||||||
|
parameters only (build the SQL from fixed fragments, never interpolate values). Cap
|
||||||
|
`limit` at 1000 in the repo too. Tests: in-memory db seeded via BatchWriter; filters,
|
||||||
|
keyset paging across a boundary, bucket alignment, empty ranges, substring escaping
|
||||||
|
(`%`/`_` in domain must not act as wildcards — use ESCAPE).
|
||||||
|
|
||||||
|
Acceptance: fmt/ast clean; temp-root (-lc -lsqlite3) green; frozen surface untouched.
|
||||||
|
|
||||||
|
### W1 As built
|
||||||
|
|
||||||
|
`pub const max_limit: u32 = 1000` exported. WHERE assembly: six comptime fragments copied
|
||||||
|
into a comptime-sized stack buffer (overflow impossible by construction), one bare `?` per
|
||||||
|
predicate, bind order = append order; `likePattern` escapes `%`, `_` and `\` with
|
||||||
|
`ESCAPE '\'`. Ruling 27 discharged: EXPLAIN QUERY PLAN over all six query shapes rides
|
||||||
|
idx_query_log_client / idx_query_log_ts / rowid — NO migration v3; the time-windowed page's
|
||||||
|
temp B-tree re-sort and the DISTINCT count's temp B-tree are inherent and cheap at household
|
||||||
|
volume. `statsTotals` computes the mean as sum/count integer division (db.Stmt has no float
|
||||||
|
column reader; avg() returns REAL) — exact microseconds, null when no row recorded a time.
|
||||||
|
NULL `block_reason`/`upstream` arrive as `""` (columnText convention; neither is ever
|
||||||
|
written as an empty string). **W6 convention, ruled now: `""` serializes as `""`, not JSON
|
||||||
|
null** — `upstream=""` already means "cache hit" per milestone-7 ruling 20 and clients
|
||||||
|
branch on `blocked`/`cache_hit`, not on reason presence. `timeseries` returns
|
||||||
|
`error.Misuse` for bucket_seconds == 0 or an i64 window overflow, returns 0 for an empty
|
||||||
|
`out` without touching the database, and aligns buckets to `since` — the HANDLER passes a
|
||||||
|
grid-aligned since per ruling 13. 14 new tests (43 in file).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session W2: repo CRUD by id
|
||||||
|
|
||||||
|
Owns every file in `src/storage/repositories/` EXCEPT queries_repo.zig (W1's), plus nothing
|
||||||
|
else. Additive only; import-path functions frozen.
|
||||||
|
|
||||||
|
For groups, clients (+prefixes), upstreams, sources, rules, local records, forward zones:
|
||||||
|
id-carrying list variants (or extend existing rows where the shape already has no
|
||||||
|
consumers outside export — check callers first; export/import must keep compiling
|
||||||
|
unchanged), `getX(db, id) db.Error!?XRow`, `insertXRow(db, item) db.Error!i64` (returns
|
||||||
|
id; distinct from frozen import inserts where semantics differ — e.g. clients insert with
|
||||||
|
hand_edited=1), `updateX(db, id, item) db.Error!void` (0 rows → error.NotFound),
|
||||||
|
`deleteX(db, id) db.Error!void` (same), `setGroupSources(db, group_id, source_ids)
|
||||||
|
db.Error!void` (transactional replace), `replaceClientPrefixes(db, items) db.Error!void`.
|
||||||
|
Respect FK constraints: deleting a group with clients → error.Constraint surfaces as 409 at
|
||||||
|
the handler; document each. `settings_repo`: `putSetting(db, key, value)` single-key upsert
|
||||||
|
if absent. Tests per resource: round trip, NotFound on both update and delete, constraint
|
||||||
|
surfacing, group-sources replace idempotence.
|
||||||
|
|
||||||
|
Acceptance: fmt/ast clean; temp-root green; `zig build test` green (export/import tests
|
||||||
|
prove the frozen surface).
|
||||||
|
|
||||||
|
### W2 As built
|
||||||
|
|
||||||
|
New shared `crud.zig`: `execStrict` turns zero-rows-touched into `error.NotFound`. Accepted
|
||||||
|
deviations: `getX(database, gpa, id) db.Error!?XRow` (rows hold heap strings);
|
||||||
|
`insertClientRow`/`insertRuleRow` take `now_s` (NOT NULL columns, repos take no Io — the
|
||||||
|
InsertContext.now mirror); client writes split `ClientInput{ip,name,group_id}` (create) vs
|
||||||
|
`ClientEdit{name,group_id}` (edit) — **ip is not editable**: it is the identity upsertSeen
|
||||||
|
matches, and ruling 9 promises only name/group on PUT; client_prefixes get list + replace
|
||||||
|
only (whole-list resource, per-id would be unused generality); write shapes carry group_id
|
||||||
|
(a missing group surfaces as the FK violation → 409, not an id-map miss); added
|
||||||
|
`listGroupSourceIds` (the PUT needs a read counterpart; frozen listGroupSources speaks
|
||||||
|
names). Constraint map for W7: deleting a group with clients → Constraint (clients.group_id
|
||||||
|
has no ON DELETE; rules/prefixes/group-sources cascade); bad group_id on client/rule writes
|
||||||
|
→ Constraint; url/ip/zone/name uniques → Constraint; deletes of clients/upstreams/rules/
|
||||||
|
local-records/forward-zones can only be NotFound. `setGroupSources`: BEGIN IMMEDIATE,
|
||||||
|
existence check first (empty set must not report success for a missing group), delete +
|
||||||
|
distinct insert (set semantics dedupe); `replaceClientPrefixes` transactional whole-table,
|
||||||
|
duplicate prefix REJECTED as Constraint (two rows for one prefix is a contradiction, not a
|
||||||
|
set). Schema facts: `upstreams.tls_name` comes from migration v2 (config_schema.zig is v1
|
||||||
|
only!) — the openapi /api/upstreams entry needs the fifth field; `SourceRow` gained
|
||||||
|
`is_suggested: bool = false` appended last (manager.zig literals compile unchanged, no
|
||||||
|
column index moved). `putSetting` is a primary-key upsert — partial settings PUT needs no
|
||||||
|
read-first and fires no constraint. 45 new tests + 3 crud.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session W3: web core — server, router, http plumbing
|
||||||
|
|
||||||
|
Owns: `src/web/server.zig`, `src/web/router.zig`, `src/web/http_util.zig`.
|
||||||
|
|
||||||
|
- server.zig: `WebState` (ruling 26 — declare the struct; fields it cannot yet point at get
|
||||||
|
wired by W9), `pub fn serve(state: *WebState, io: std.Io) std.Io.Cancelable!void` — bind
|
||||||
|
per ruling 5 (bind failure: warn and return — the DNS side must keep serving; app treats
|
||||||
|
web bind failure as non-fatal, W9 documents), accept loop per ruling 4 with the Stop
|
||||||
|
split, connection budget per ruling 7 (503 over cap), per-connection buffers (8 KiB recv
|
||||||
|
/ 4 KiB send), keep-alive loop, dispatch into router.
|
||||||
|
- router.zig: `RouteInfo { method: http.Method, pattern: []const u8, auth: enum {open,
|
||||||
|
session}, handler: *const fn(...) }`; `pub const routes: []const RouteInfo` (ruling 23
|
||||||
|
depends on it); match = exact segments + one `{id}` numeric capture; 405 with Allow when
|
||||||
|
the path matches another method; target copied before body reads (ruling 25); query-pair
|
||||||
|
iterator + percent-decode helpers (in http_util, pure, tested hard: '+', '%2F', truncated
|
||||||
|
%, overlong values → 400).
|
||||||
|
- http_util.zig: JSON respond helpers (`std.json.Stringify` streaming into a
|
||||||
|
`Writer.Allocating` for content-length, or direct respond for small bodies), the error
|
||||||
|
envelope, request-body reader with the 1 MiB cap (413), cookie parse/format, bearer of
|
||||||
|
nothing else.
|
||||||
|
- The connection handler calls: limiter (W4, via a comptime-checkable interface field on
|
||||||
|
WebState so W3 compiles before W4 lands — define `ApiLimiter` and `Sessions` as W3-owned
|
||||||
|
INTERFACE structs? NO: simpler, W3 declares the WebState fields as `*auth.Sessions` /
|
||||||
|
`*auth.ApiLimiter` types and W3 is built AFTER W4's file exists on disk in the same wave
|
||||||
|
— to keep the wave parallel, W3 instead keeps auth/limiter checks behind two function
|
||||||
|
pointers on WebState (`check_auth`, `check_limit`) that W9 wires; W3 tests them with test
|
||||||
|
doubles. This is the one seam where indirection is warranted; document it.)
|
||||||
|
- Tests: router matching table, 405/404, query decoding, body cap, cookie round trip,
|
||||||
|
keep-alive across two requests (loopback socket), 503 over cap, cancel-during-idle
|
||||||
|
keep-alive returns promptly.
|
||||||
|
|
||||||
|
Acceptance: fmt/ast clean; temp-root green (may import std only + own files); no
|
||||||
|
listener/handler file touched.
|
||||||
|
|
||||||
|
### W3 As built
|
||||||
|
|
||||||
|
Handler signature: `fn(state: *server.WebState, io: std.Io, request: *http_util.Request)
|
||||||
|
http_util.HandlerError!void`, `HandlerError = {WriteFailed, HttpExpectationFailed,
|
||||||
|
OutOfMemory}` — domain outcomes are status codes, only those three escape. `Request` folds
|
||||||
|
the id capture in (`request.id: ?i64`; no separate params type) and carries a per-request
|
||||||
|
arena. `RouteInfo` gained `rate_limit: enum{counted,exempt} = .counted` (ruling 19's
|
||||||
|
exemptions as data, not path matching); WebState gained `fallback: ?HandlerFn` (SPA
|
||||||
|
fallback is not a route entry; unmatched non-/api → fallback, unmatched /api → JSON 404)
|
||||||
|
and `reload_fn` (W7's ruling-12 seam). routes.zig: W8 fills `pub const table`; router
|
||||||
|
re-exports as `routes`; WebState.routes defaults to it, so the drift guards read the array
|
||||||
|
the server matches. W4/W5 landed mid-session, so sessions/limiter/hub/sink are REAL typed
|
||||||
|
pointers and check_auth/check_limit default to real implementations (`sessionAuth`,
|
||||||
|
`bucketLimit`; `allowAll`/`neverLimit` exported as test doubles) — a half-wired server
|
||||||
|
fails closed. serve returns void (cancellation is classified into the Stop enum;
|
||||||
|
tcp_server precedent). Own validating percent-decoder — std.Uri's copies malformed escapes
|
||||||
|
through as literal text (Uri.zig:172-192), ruling 29 wants 400; segments split before
|
||||||
|
decode so %2F cannot forge a boundary. Loopback tests live in web/server_integration_test
|
||||||
|
.zig (house gating pattern). Accepted tradeoff: over-capacity 503 is written and the
|
||||||
|
socket closed without draining, so the client may see RST instead — draining would be an
|
||||||
|
unbounded blocking read on an at-capacity accept loop (documented). RULINGS RECORDED:
|
||||||
|
W7 may add the `local_tables` field + import to server.zig (two-line exception, W3
|
||||||
|
consents); Retention.stats is a cross-task data race — W6 owns storage/retention.zig
|
||||||
|
additively this wave: atomic counters + `snapshotStats` (logger-counters pattern), and
|
||||||
|
/metrics reads only through it. 39 tests.
|
||||||
|
|
||||||
|
**Amendment (post-W9 critical fix)**: `serveConn` normalizes the request head immediately
|
||||||
|
after `receiveHead`: a body-carrying method (POST/PUT/PATCH) with neither Content-Length
|
||||||
|
nor Transfer-Encoding gets `head.content_length = 0` before any dispatch or respond.
|
||||||
|
RFC 9110 §8.6 defines such a request as having an empty body, but std leaves the head
|
||||||
|
saying "unknown" and `respond` → `discardBody` (http/Server.zig:631) asserts one of the
|
||||||
|
two is set — so a bare `curl -X POST` panicked the whole process, DNS included. Zero
|
||||||
|
content-length satisfies every downstream path: `bodyReader` (http.zig:445) goes straight
|
||||||
|
to `.ready`, `discardRemaining` sees EndOfStream, keep-alive survives, and
|
||||||
|
`http_util.readBody` yields an empty slice. Regression test in
|
||||||
|
server_integration_test.zig: raw POST without either header → well-formed response, empty
|
||||||
|
body observed by the handler, fresh connection answered after, connection_errors 0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session W4: auth + API limiter
|
||||||
|
|
||||||
|
Owns: `src/web/auth.zig`, `src/web/api_limiter.zig`, plus the `model.zig`/`validate.zig`
|
||||||
|
additions for `web.api_localhost_exempt` (ruling 19) — model field, validate rule (none
|
||||||
|
needed beyond type), settings key list, and the export/import round-trip expectations
|
||||||
|
(check model.toSettings reflection picks it up automatically; add the settings-count test
|
||||||
|
adjustments).
|
||||||
|
|
||||||
|
- auth.zig: `Sessions` fixed 32-slot store per ruling 17 (create → cookie value out;
|
||||||
|
validate cookie → bool; logout; clearAll; expiry sweep on access; LRU evict), login
|
||||||
|
verify via argon2 strVerify (io + allocator required — argon2.zig:600), token =
|
||||||
|
32 bytes randomSecure → url_safe_no_pad; storage = SHA-256 digest; compare
|
||||||
|
timing_safe.eql([32]u8,...). `authEnabled(cfg) bool`. Pure unit tests with fixed tokens
|
||||||
|
(seed io.random? no — inject the token bytes via a testable `createWithToken`).
|
||||||
|
- api_limiter.zig: token bucket per ruling 19, fixed 4096-key table (house idiom), awake
|
||||||
|
clock, `check(now, key, is_localhost) Result{allowed, retry_after_s}`; localhost exempt
|
||||||
|
when configured; `sse_connections` per-IP counter with `tryAcquireSse/releaseSse` against
|
||||||
|
`sse_max_connections_per_ip`. Tests: refill math at boundaries, burst=capacity, exempt
|
||||||
|
localhost, sse cap acquire/release, eviction.
|
||||||
|
|
||||||
|
Acceptance: fmt/ast clean; temp-root green; `zig build test` green (model/validate/export
|
||||||
|
tests still pass with the new field).
|
||||||
|
|
||||||
|
### W4 As built
|
||||||
|
|
||||||
|
Sessions: digests only (SHA-256), full-slot scan on validate so work does not depend on
|
||||||
|
match position; `verifyPassword` returns `Outcome{ok,denied,unavailable}` — `.unavailable`
|
||||||
|
(broken PHC string, OOM) maps to 500, not 401; nothing secret is ever formatted (warn
|
||||||
|
prints `@errorName` only). `createWithToken`/`validateAt` are the test seams. Cookie value
|
||||||
|
is 43 chars url_safe_no_pad; `cookie_attributes = "HttpOnly; SameSite=Lax; Path=/"`.
|
||||||
|
Limiter deviations, accepted: (1) `check(io, now, addr)` — the limiter holds its OWN
|
||||||
|
std.Io.Mutex (W3 dispatches connections concurrently; one lock inside beats N outside) and
|
||||||
|
derives loopback itself via exported `isLoopback`, so callers cannot disagree; (2) refill
|
||||||
|
counts millionths of a token and advances the clock only by the span converted, so the
|
||||||
|
truncated remainder survives — at 1 req/min the 60 s boundary is exact (tests at 999/1000
|
||||||
|
ms); (3) the SSE per-IP cap binds loopback clients too — `api_localhost_exempt` exempts
|
||||||
|
the RATE, not the fixed hub-slot resource. Full table → unknown addresses allowed +
|
||||||
|
`untracked` (DNS-limiter precedent); `sweep` drops only full, SSE-free, window-idle
|
||||||
|
buckets; `retry_after_s` never 0 on refusal. Model ripple: `web.api_localhost_exempt` in
|
||||||
|
Web + expected_keys + fixture; validate.zig needed no edit (bool; decodeValue already
|
||||||
|
rejects non-bool text); no count assertion outside W4 files existed. 12 auth + 14 limiter
|
||||||
|
tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session W5: query sink, SSE hub, logger split
|
||||||
|
|
||||||
|
Owns: `src/server/query_sink.zig` (new), `src/web/sse.zig` (new), `src/storage/logger.zig`
|
||||||
|
(additive split ONLY: `transformed(entry) Entry` pure + `logTransformed(io, entry)`;
|
||||||
|
existing `log` becomes transform+logTransformed with byte-identical behavior — the
|
||||||
|
existing tests must pass unchanged), and `src/server/handler.zig` (mechanical: field
|
||||||
|
`logger: ?*logger_mod.Logger` → `sink: ?*query_sink.QuerySink`, the log call site, tests
|
||||||
|
updated to construct a sink around their logger).
|
||||||
|
|
||||||
|
- query_sink.zig per ruling 20: publish BEFORE enqueue (PLAN:455).
|
||||||
|
- sse.zig `Hub`: 32 slots × 64-entry Entry rings + Event per subscriber;
|
||||||
|
`subscribe() ?SubscriberId` / `unsubscribe(id)`; `publish(io, entry)` copies the entry
|
||||||
|
into every live ring (Entry is self-contained, ~400 B; a full ring marks the subscriber
|
||||||
|
`overflowed` and sets its event — the subscriber task sees the flag and ends the
|
||||||
|
response); `wait(id, timeout)` for the heartbeat loop. All under one mutex; publish is
|
||||||
|
called on the DNS hot path — it must stay allocation-free and short (copy + flag set).
|
||||||
|
Tests: publish/consume order, overflow disconnect flag, unsubscribe under load,
|
||||||
|
heartbeat timeout returns empty.
|
||||||
|
- Handler tests: one added test proving the sink publishes and logs (tiny hub + logger).
|
||||||
|
|
||||||
|
Acceptance: fmt/ast clean; temp-root green including ALL existing handler and logger
|
||||||
|
tests; `zig build test` + `-Dintegration` green (phase7 tests construct Handlers — they
|
||||||
|
compile against the renamed field; update them, they are in your ownership for THIS
|
||||||
|
mechanical change only — list every touched line in the report).
|
||||||
|
|
||||||
|
### W5 As built
|
||||||
|
|
||||||
|
Every Hub method takes `io` (the mutex needs it); all locking is `lockUncancelable`
|
||||||
|
(milestone-7 tracker precedent: `handle` has no error union). `Hub.init(self) void`
|
||||||
|
initializes IN PLACE — 32×64 rings ≈ 900 KiB, so **W9 must `gpa.create(Hub)`, never a
|
||||||
|
stack local**. Read surface ruling 20 left open, as built: `next(io, id) ?Entry` (pop
|
||||||
|
oldest), `overflowed(io, id) bool` (sticky; pre-overflow entries stay readable — the
|
||||||
|
subscriber drains then disconnects), `wait(io, id, timeout) Cancelable!Wake` with
|
||||||
|
`Wake = {ready, timeout}`; `wait` resets the event under the mutex only while the ring is
|
||||||
|
empty, so a racing publish is never lost, and a spurious futex wake reports `.timeout`
|
||||||
|
(costs one heartbeat). Publish-before-persist is pinned structurally (enqueue never
|
||||||
|
blocks, so a clock cannot observe order): the test closes the logger queue and the entry
|
||||||
|
still reaches the hub while counting `queries_dropped`. W5 also made the minimal app.zig
|
||||||
|
compile fix (sink around the logger, hub = null, `.sink = &sink`) — **W9: the sink already
|
||||||
|
exists in app.zig; only the hub and the rest of the wiring are missing.** 11 sse + 4 sink
|
||||||
|
+ 1 logger-split + 1 handler test; all pre-existing logger/handler tests unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session W6: read handlers + metrics + health + version + openapi route
|
||||||
|
|
||||||
|
Owns: `src/web/handlers/stats.zig`, `queries.zig`, `lookup.zig`, `upstream_health.zig`,
|
||||||
|
`health.zig`, `version.zig`, `metrics.zig` (in web/, not handlers/ — PLAN layout :227).
|
||||||
|
|
||||||
|
Implement rulings 11, 13, 14, 21, 22; `GET /api/version` = version.zig string + git commit
|
||||||
|
build option. Every handler is `fn(state, io, request-ish, params) !void` matching W3's
|
||||||
|
handler signature; JSON via http_util. Metrics: mind the mutex discipline (cache/limiter
|
||||||
|
stats under the handler's mutexes; pool snapshot copies borrowed strings immediately;
|
||||||
|
atomics via .monotonic loads). Handlers read the querylog via state's own connection.
|
||||||
|
Tests: pure formatting tests per handler where the data is injectable (metrics text
|
||||||
|
golden test, health degraded matrix, stats bucket math via W1 fixtures on an in-memory
|
||||||
|
db).
|
||||||
|
|
||||||
|
Acceptance: fmt/ast clean; temp-root green.
|
||||||
|
|
||||||
|
### W6 As built
|
||||||
|
|
||||||
|
Every handler is split into a pure core plus a thin `handle`, so the decisions are tested
|
||||||
|
without an `http.Server.Request`: `metrics.collect`/`metrics.render`, `health.collect`/
|
||||||
|
`rollup`, `stats.window`/`periodParam`, `queries.parseFilter`/`page`, `lookup.evaluate`/
|
||||||
|
`body`, `upstream_health.collect`, `version.body`. Entry points for W8's table:
|
||||||
|
`metrics.handle`, `stats.totals`, `stats.timeseries`, `queries.list`, `lookup.handle`,
|
||||||
|
`upstream_health.handle`, `health.handle`, `version.handle` — all match `router.HandlerFn`
|
||||||
|
(proven by a temp-root assignment). `/metrics` and `/api/health` are `auth = .open,
|
||||||
|
rate_limit = .exempt`; `/api/version` is `.open, .counted`; the other four are `.session,
|
||||||
|
.counted`. **W8 must add `_ = @import("web/metrics.zig")` and the six handler files to
|
||||||
|
src/tests.zig** — this session did not wire the orchestrator's file.
|
||||||
|
Retention (ruled in W3's As built): `Stats` is now the plain snapshot type and the live
|
||||||
|
counters moved to a private `Counters` of `std.atomic.Value(u64)` in `Retention.counters`;
|
||||||
|
`snapshotStats()` reads them `.monotonic`; `runOnce` derives its pass number from the
|
||||||
|
`fetchAdd` result rather than re-reading. Test assertions changed mechanically at
|
||||||
|
retention.zig lines 192-195, 215, 220, 234, 238-240, 243-244, 258-260, 283-286, 312-313 and
|
||||||
|
at phase6_integration_test.zig lines 365-368 (`x.stats.f` → `x.snapshotStats().f`) — that
|
||||||
|
second file is the only consumer outside retention.zig, and it compiles solely under
|
||||||
|
`-Dintegration`, so the plain suite does not catch a break there. No behavior changed.
|
||||||
|
Decisions and deviations, all accepted:
|
||||||
|
- `/metrics` carries the DNS counters as an ARRAY keyed by `Handler.Stats`'s comptime field
|
||||||
|
list, so a new counter in the handler appears in the exposition with no edit here; the
|
||||||
|
same `counterGroup` helper walks every plain stats struct. Missing collaborator = the
|
||||||
|
whole family is omitted (a zero would read as health, an absent series reads as a gap).
|
||||||
|
- Added beyond ruling 21's list: `nxdns_disk_sample_failures_total` (a failure mode that
|
||||||
|
ruling 22 already surfaces) and `nxdns_upstream_enabled` (an upstream that is down and
|
||||||
|
one that is switched off are different operator problems). Logger `writer_failed` is
|
||||||
|
deliberately NOT a metric: it is in `/api/health` and would need a gauge family of one.
|
||||||
|
- `metrics.poolSnapshot` is the one place that copies pool health, shared with the health
|
||||||
|
rollup: cancel protection around `Pool.snapshot` (server.zig's precedent), then every
|
||||||
|
borrowed string duped into the request arena immediately. Cache/limiter stats are read
|
||||||
|
under `Handler.cache_mutex`/`limiter_mutex` with `lockUncancelable` — `HandlerError` has
|
||||||
|
no `Canceled`, so the W5 precedent applies.
|
||||||
|
- Ruling 13's window: `until` = end of the bucket `now` falls in, `since = until - width ×
|
||||||
|
count`, both on the absolute epoch grid (every width divides 86 400, asserted at
|
||||||
|
comptime), so `/api/stats` and `/api/stats/timeseries` cover the identical span — tested
|
||||||
|
by summing the buckets against the totals. Both bodies also carry `period`, `since` and
|
||||||
|
`until` (a chart cannot label an axis without them); the timeseries body adds
|
||||||
|
`bucket_seconds`. **openapi.yaml must document those fields.**
|
||||||
|
- `/api/queries`: `limit` outside 1..1000 is a 400 rather than a silent clamp, `before` must
|
||||||
|
be positive, and each bad parameter has its own message. `next_before` is the last row's
|
||||||
|
id only on a full page. `domain=`/`client=` empty read as absent.
|
||||||
|
- `/api/lookup` resolves `group_id` through `groupIndexById`; an id no snapshot group has is
|
||||||
|
a 400 (`unknown group_id`), a missing manager or snapshot is 503 `no snapshot loaded`, and
|
||||||
|
the domain runs through `name.fromText` + `matcher.normalize` (ruling 29). `source_url`
|
||||||
|
comes from `sources_repo.getSource` on the config connection keyed by the snapshot's
|
||||||
|
source row id — `SourceSets` carries the display name, not the URL; an unreadable row
|
||||||
|
leaves the field null rather than failing the lookup. Records and zones are read through
|
||||||
|
`state.handler.?.local_tables` (acquire/release bracketed by one defer), NOT a WebState
|
||||||
|
field, because that pointer already exists and is the same table W7 swaps.
|
||||||
|
- `/api/upstream/health` reports url, enabled, available, consecutive_failures,
|
||||||
|
total_successes, total_failures, success_rate, last_error, plus `available`/`total`
|
||||||
|
rollup counts. No timestamps: they are `awake`-clock stamps and mean nothing to a client.
|
||||||
|
- `/api/version` adds `zig_version` and `uptime_seconds` (from `WebState.started_unix`) to
|
||||||
|
the version and commit strings.
|
||||||
|
- The only logging in this session is one `warn` per failed database read or pool/source
|
||||||
|
fault, on the 500 path; nothing is logged for a normal request, and no `std.log.err`.
|
||||||
|
40 new tests (metrics 5, stats 8, queries 9, lookup 7, health 4, upstream_health 3,
|
||||||
|
version 4); retention's 6 tests unchanged in number and green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session W7: mutation handlers + local_tables + pause + settings + auth handlers
|
||||||
|
|
||||||
|
Owns: `src/web/handlers/` `groups.zig`, `blocklists.zig`, `rules.zig`, `local.zig`,
|
||||||
|
`clients.zig`, `settings.zig`, `pause.zig`, `auth.zig` (login/logout), plus
|
||||||
|
`src/server/local_tables.zig` (new, ruling 12) and the `src/server/handler.zig` edit to
|
||||||
|
read records/zones through it (second mechanical handler change; coordinate: W5 already
|
||||||
|
edits handler.zig — W7 runs AFTER W5 lands; sequence inside wave 2).
|
||||||
|
|
||||||
|
- local_tables.zig: `LocalTables { lock: std.Io.RwLock, records, zones }` with
|
||||||
|
`acquire/release` (shared) and `swap(io, new_records, new_zones)` (exclusive; frees the
|
||||||
|
old under the lock after swap — no reader can hold across queries since acquire/release
|
||||||
|
brackets each query). Handler: acquire in handle, release on every exit (same single
|
||||||
|
defer discipline as the snapshot).
|
||||||
|
- Handlers: rulings 9, 12, 15, 16, 17-18 behaviors; every mutation validates (shared
|
||||||
|
validators — reuse validate.zig section rules by constructing the candidate row set;
|
||||||
|
where validate.zig only does whole-config, extract the per-section check it already has
|
||||||
|
— validate.zig is NOT in your ownership: if extraction is needed, report it and use a
|
||||||
|
local check this milestone), writes via W2 repos on state's config connection, then
|
||||||
|
reload/rebuild per ruling 12. blocklists/update → refreshAll + reload, 202 + status.
|
||||||
|
settings PUT: merge, validate whole config, write keys, clear sessions on hash change.
|
||||||
|
- Tests: per handler with in-memory dbs and a real Manager where cheap, else assert repo
|
||||||
|
effects + reload-called flag via a seam (a `reload_fn` pointer on WebState set by W9;
|
||||||
|
tests inject a counter).
|
||||||
|
|
||||||
|
Acceptance: fmt/ast clean; temp-root green; phase7 integration still green.
|
||||||
|
|
||||||
|
### W7 As built
|
||||||
|
|
||||||
|
Eleven files, 4199 lines, 89 tests (local_tables 5, mutations 6, groups 13, blocklists 8,
|
||||||
|
rules 7, local 12, clients 9, upstreams 6, pause 7, settings 10, auth 6).
|
||||||
|
- **The apply/respond split (house pattern for later waves).** `http_util`'s response
|
||||||
|
helpers need a live `*http.Server.Request`, which a unit test cannot construct. Every
|
||||||
|
route splits into an `apply*` decision function driven against an in-memory database and
|
||||||
|
a thin HTTP wrapper.
|
||||||
|
- **`mutations.zig` is a new shared file, owned by W7.** It holds the `Failure` union, the
|
||||||
|
constraint map (NotFound → 404, Constraint → 409, validation → 400), the `reload` and
|
||||||
|
`swapLocalTables` seams, and the `Bench` test fixture.
|
||||||
|
- **`config_lock` lives on WebState** (ruled post-report): connection tasks share one
|
||||||
|
config database connection, and `changes()`/`lastInsertRowid()` are connection state that
|
||||||
|
`execStrict` reads, so writes serialize through `state.config_lock`.
|
||||||
|
- **Per-row validation**: a minimal valid skeleton config plus the one candidate row runs
|
||||||
|
the real `validate.validate`; no validator duplicated. Duplicate keys and foreign-key
|
||||||
|
violations surface from the database as 409, not 400.
|
||||||
|
- **`upstreams.zig` is W7's** (ruling 9 listed the resource; neither ownership list named
|
||||||
|
it). It never calls the reload seam, refuses to delete the last enabled upstream (409),
|
||||||
|
and reports `restart_required: true`.
|
||||||
|
- **Ruled additions**: `GET /api/settings` returns a derived `web.auth_enabled` flag; the
|
||||||
|
default group cannot be renamed or deleted (409); a group that still holds clients cannot
|
||||||
|
be deleted (409).
|
||||||
|
- **handler.zig**: `records`/`zones` replaced by `local_tables: ?*LocalTables = null`;
|
||||||
|
`handle` acquires once per query, releases on defer — one query reads one generation of
|
||||||
|
records, zones and the filter snapshot together. `Context` gained `records`/`zones`.
|
||||||
|
- **Ruled mechanical ripples**: web/server.zig two-line exception (import + WebState
|
||||||
|
field, plus the config_lock field above); app.zig minimal fix (`var tables: LocalTables
|
||||||
|
= .empty` + build calls — **W9 must review**); udp/tcp/resolver integration tests lost
|
||||||
|
their `empty_records`/`empty_zones` consts and two `bareHandler` initialisers; phase7
|
||||||
|
test converted to real tables where needed.
|
||||||
|
- **Routes for W8** (all match W3's signature; id routes read `request.id.?`): groups
|
||||||
|
`list get create update remove getSources putSources`; blocklists `list get create
|
||||||
|
update remove refresh` (refresh = POST /api/blocklists/update, 202 + status snapshot);
|
||||||
|
rules `list get create update remove`; local `listRecords getRecord createRecord
|
||||||
|
updateRecord removeRecord listZones getZone createZone updateZone removeZone`; clients
|
||||||
|
`list get update remove listPrefixes putPrefixes` (no create); upstreams `list get
|
||||||
|
create update remove`; pause `get post`; settings `get put`; auth `login logout`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session W8: static assets, build pipeline, openapi.yaml, route table completion
|
||||||
|
|
||||||
|
Owns: `src/web/static.zig`, `src/web/openapi.zig`, `src/web/openapi.yaml`,
|
||||||
|
`web/dist-placeholder/` (index.html + favicon.svg), `build.zig` (the -Dweb-dist option +
|
||||||
|
asset module generation), and the final `router.zig` route-table registration IF W3 left
|
||||||
|
registration data-driven (coordinate: W3 owns router.zig — W8 only ADDS the route array
|
||||||
|
entries file `src/web/routes.zig` if W3 designed it that way; otherwise W8 hands W3's
|
||||||
|
owner a patch — resolve by making W3 expose `routes` as a separate file owned by W8 from
|
||||||
|
the start; W3 must read this paragraph).
|
||||||
|
|
||||||
|
Ruling 24 in full: asset module via addWriteFiles + generated index; etag comptime hash;
|
||||||
|
gzip sibling serving; SPA fallback; --web-dev disk serving (cli flag lands in W9 — W8
|
||||||
|
exposes `serveFromDisk(dir, ...)`). openapi.yaml documents EVERY route in the table with
|
||||||
|
schemas matching the handlers' JSON (snake_case, error envelope, auth markers, 429/401
|
||||||
|
responses). Tests: static matching, 304 flow, gzip negotiation, SPA fallback vs /api 404,
|
||||||
|
placeholder embeds and serves.
|
||||||
|
|
||||||
|
Acceptance: fmt/ast clean; `zig build` with default dist green; temp-root green.
|
||||||
|
|
||||||
|
### W8 As built
|
||||||
|
|
||||||
|
Seven files: static.zig 362, openapi.zig 66, routes.zig 194 (55 routes), openapi.yaml
|
||||||
|
2255, handlers/live.zig 184 (ruled mid-session — no session owned the SSE HTTP handler),
|
||||||
|
tools/gen_web_assets.zig 201 (new build tool, W8's), web/dist-placeholder (real page:
|
||||||
|
fetches /api/health + /api/version, renders status/upstreams/disk). 21 new tests
|
||||||
|
(static 9, openapi 3, routes 6, live 3).
|
||||||
|
- **Asset pipeline**: gzip cannot run in the build system itself, so
|
||||||
|
`tools/gen_web_assets.zig` runs as a build step: dist staged through WriteFiles (Run
|
||||||
|
hashes only a directory arg's path string; staging bakes the content hash into the
|
||||||
|
path, so edits invalidate — proven), tool output merged with generated `.gz` siblings
|
||||||
|
and `assets.zig` into the `web_assets` anonymous import (exe, cross exes, tests).
|
||||||
|
- **`web_assets` module surface**: `File{path, bytes, content_type, etag}`,
|
||||||
|
`files: []const File`; root is the generated assets.zig.
|
||||||
|
- **ETag** computed in the tool, not comptime (same build-time constant; avoids the
|
||||||
|
comptime interpreter hashing large M9 bundles). SHA-256/128-bit, quoted, strong.
|
||||||
|
`.gz` siblings are their own `files` entries with their own ETag; direct `*.gz` paths
|
||||||
|
are not addressable. The tool skips gz when it does not shrink or below 128 bytes.
|
||||||
|
- **static.zig surface for W9**: `state.fallback = static.fallback` (embedded) or wrap
|
||||||
|
`static.serveFromDisk(dir, io, request)` in a HandlerFn for `--web-dev` (app.zig owns
|
||||||
|
the dir storage; no cache headers in dev mode). Traversal guard in `diskRelativePath`.
|
||||||
|
- **live.zig** (SSE, ruling 20): respondStreaming with empty buffer, `retry: 3000`,
|
||||||
|
`event: query` frames, 15 s heartbeat (awake clock) via `Hub.wait`, per-IP cap via
|
||||||
|
`tryAcquireSse`/`releaseSse`, ring overflow ends the stream with a clean chunked
|
||||||
|
terminator so EventSource reconnects. RULED: route is `.session, .exempt` (overrides
|
||||||
|
ruling 19's connect-token line; the per-IP SSE cap is the guard, loopback included).
|
||||||
|
RULED: the SSE payload omits `id` (a live entry precedes persistence); a comptime test
|
||||||
|
pins the remaining nine field names to `QueryRow`.
|
||||||
|
- **Route policy**: open = {/metrics, /api/health, /api/version, /api/openapi.yaml,
|
||||||
|
/api/auth/login}; limiter-exempt = {/metrics, /api/health, /api/queries/live}; logout
|
||||||
|
is `.session` (ruling 18).
|
||||||
|
- **openapi.yaml** documents all 55 routes, cookie security scheme, 401 on every session
|
||||||
|
route, 429 + Retry-After on every counted route, the W6 stats fields (period, since,
|
||||||
|
until, bucket_seconds), SSE as text/event-stream; no certs/reload. openapi.zig's test
|
||||||
|
enforces route ⊆ yaml at unit level; W10 still owns the count-equality drift guard (b).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session W9: app + cli wiring
|
||||||
|
|
||||||
|
Owns: `src/app.zig`, `src/cli.zig`, `src/main.zig` (if forced).
|
||||||
|
|
||||||
|
WebState assembly (ruling 26) gated on `web.enabled`; two extra DB connections; hub +
|
||||||
|
sink construction (sink wraps logger always — hub only when web enabled; DNS path cost
|
||||||
|
without web = one null check); local_tables construction replacing the direct
|
||||||
|
records/zones locals; web serve task in the group (started last); reload_fn/check seams
|
||||||
|
wired; `--web-dev <dir>` CLI flag (parseArgs + help text + runCheck untouched); web bind
|
||||||
|
failure = warn + continue serving DNS (ruling in W3 — confirm and document in app).
|
||||||
|
Shutdown order unchanged (web task dies with group.cancel; its connection group cancels
|
||||||
|
per ruling 4). Smoke test yours: boot with web enabled, curl /api/health, /api/version,
|
||||||
|
/metrics, /, login flow with a password set (import a config with web.password, verify
|
||||||
|
cookie + 401 without), SIGTERM exit 0. Report the transcript.
|
||||||
|
|
||||||
|
Acceptance: fmt/ast clean; zig build; both test suites green; smoke transcript.
|
||||||
|
|
||||||
|
### W9 As built
|
||||||
|
|
||||||
|
- `cli.Command.run` payload is now `RunArgs { paths: Paths, web_dev: ?[]const u8 }`;
|
||||||
|
`parseRunArgs` handles `--web-dev DIR` (both spellings); `check` rejects it;
|
||||||
|
`runCheck` untouched. Anything spawning `app.run` passes `RunArgs`.
|
||||||
|
- app.zig serve order: zero-guard (`web.api_rate_limit_per_min`/`session_ttl_hours` zero
|
||||||
|
on an enabled web → `error.BadRateLimit`, exit 2 — collaborators assert nonzero and a
|
||||||
|
hand-edited database bypasses validate); heap `sse.Hub` via `gpa.create` + in-place
|
||||||
|
init (W5 rule), gated on `web.enabled`; sink wraps `(&logger, hub)` ALWAYS (hub null
|
||||||
|
without web = one branch); two web DB connections (openConfigDb + reopenQuerylogDb,
|
||||||
|
after openQuerylogDb establishes the file), gated; Sessions + ApiLimiter, gated;
|
||||||
|
WebState assembled after the DNS handler (`reload_fn = app.reloadManager`,
|
||||||
|
`fallback = static.fallback` or the dev wrapper, version.string, started_unix); web
|
||||||
|
task added to the group LAST; bind failure = warn + return, DNS keeps serving.
|
||||||
|
Shutdown order unchanged. `web.enabled = false` → no hub, no web DBs, no
|
||||||
|
sessions/limiter, no web task.
|
||||||
|
- `--web-dev` dir lives in a file-scope var in app.zig (`WebState.fallback` is a bare fn
|
||||||
|
pointer, no closure; one composition root; written once before the task starts).
|
||||||
|
- W7's `var tables: LocalTables` holder confirmed correct: `WebState.local_tables`
|
||||||
|
points at the same holder the DNS handler reads, so `swapLocalTables` swaps both.
|
||||||
|
- Mechanical ripple: phase7 test line 967 `cli.Paths{...}` → `RunArgs{ .paths = ... }`.
|
||||||
|
- Smoke test passed end to end: import with web.password → boot → 200 on /api/health,
|
||||||
|
/api/version, /metrics, /, /api/openapi.yaml → 401 without cookie → login sets
|
||||||
|
HttpOnly/SameSite=Lax cookie → authed read → logout kills the cookie → wrong password
|
||||||
|
401 → SIGTERM exit 0. No secrets in logs. `--web-dev` smoked: disk edits appear
|
||||||
|
without restart.
|
||||||
|
- **Critical finding (fixed post-session in W3's files)**: a bodyless POST/PUT — no
|
||||||
|
Content-Length, no Transfer-Encoding, exactly `curl -X POST` — hit the
|
||||||
|
`discardBody` assert in std (Server.zig:631) and panicked the process, DNS included.
|
||||||
|
RFC 9110 gives such a request an empty body, so the fix normalizes in the request
|
||||||
|
path before any respond call (see W3 As-built amendment); W10 pins it with a contract
|
||||||
|
test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session W10: integration + contract tests + CI
|
||||||
|
|
||||||
|
Owns: `src/web/web_integration_test.zig` (new), `.gitea/workflows/ci.yml` (additive job
|
||||||
|
steps only).
|
||||||
|
|
||||||
|
Contract table per ruling 23 covering every route; auth on/off matrix (seeded
|
||||||
|
password_hash vs empty); SSE test (connect, cause one query via a real Handler or direct
|
||||||
|
sink.publish, assert `event: query` frame + heartbeat + cap enforcement via
|
||||||
|
sse_max_connections_per_ip=1); rate-limit 429 + Retry-After; pagination walk; mutation →
|
||||||
|
reload observed (snapshot generation bump); pause via API affects a real handler decision;
|
||||||
|
settings PUT round trip; static + ETag 304; drift guards (a) and (b). CI: ensure the
|
||||||
|
integration job still covers everything (contract tests ride -Dintegration; add a
|
||||||
|
`zig build test -Dintegration` step only if missing — read the current yml first).
|
||||||
|
|
||||||
|
Acceptance: `zig build test -Dintegration` green with zero err lines; plain suite green.
|
||||||
|
|
||||||
|
### W10 As built
|
||||||
|
|
||||||
|
web_integration_test.zig, 1406 lines, 14 tests: 11 integration-gated (contract walk over
|
||||||
|
all 55 routes with strict `ignore_unknown_fields = false` response shapes, auth on, auth
|
||||||
|
off, 429 + Retry-After, SSE, pagination walk, mutation → reload, pause, settings round
|
||||||
|
trip, static/ETag 304, bodyless POST) + 3 ungated pure guards (contract-covers-routes,
|
||||||
|
drift guard a route ⊆ yaml, drift guard b: 55 yaml operations == router.routes.len).
|
||||||
|
ci.yml UNTOUCHED — its test job already runs `zig build test -Dintegration` (line 25).
|
||||||
|
- Environment fully real except two seams: real Manager/reload over an in-memory config
|
||||||
|
DB, real Sessions/ApiLimiter/Hub (heap Hub per W5 rule), seeded querylog via
|
||||||
|
BatchWriter. The pool transport and fetcher are never invoked —
|
||||||
|
POST /api/blocklists/update runs in the walk BEFORE any source row exists (hermetic).
|
||||||
|
- SSE reader de-frames chunked transfer coding (the unbuffered SSE writer emits one frame
|
||||||
|
as many chunks; std's chunked writer emits each chunk's closing CRLF lazily — reading
|
||||||
|
separators eagerly deadlocks until the next heartbeat).
|
||||||
|
- Heartbeat asserted against the real 15 s interval (`live.heartbeat_interval` is not
|
||||||
|
injectable): the SSE test runs ~15-20 s in a 40 s budget; the integration suite gained
|
||||||
|
roughly 45 s of wall clock. Accepted rather than weakening the assertion.
|
||||||
|
- Pause test drives Handler.handle directly with real DNS packets (phase7 queryFor
|
||||||
|
pattern): blocked → API pause → forwarded (paused_queries bumps) → unpause → blocked.
|
||||||
|
- Mutation-reload observed as generation 1 → 2 plus the rule live via GET /api/lookup.
|
||||||
|
- Contract markers asserted by lookup into router.routes (method+pattern → auth and
|
||||||
|
rate_limit equality, exact one-to-one coverage); response shapes reference the
|
||||||
|
handlers' pub types where they exist; the strict settings parse also proves
|
||||||
|
`web.password*` never serializes.
|
||||||
|
- One expected `warn` line ("web login refused") from the wrong-password case; zero err
|
||||||
|
lines suite-wide; no secrets logged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module layout (new)
|
||||||
|
|
||||||
|
src/web/{server,router,http_util,auth,api_limiter,sse,static,openapi,metrics}.zig,
|
||||||
|
src/web/routes.zig (W8), src/web/handlers/{auth,stats,queries,clients,groups,blocklists,
|
||||||
|
rules,local,lookup,pause,settings,upstream_health,health,version}.zig,
|
||||||
|
src/server/{query_sink,local_tables}.zig, src/web/openapi.yaml, web/dist-placeholder/,
|
||||||
|
src/web/web_integration_test.zig.
|
||||||
|
|
||||||
|
## File ownership
|
||||||
|
|
||||||
|
W1 queries_repo; W2 other repositories/*; W3 web/{server,router,http_util}; W4
|
||||||
|
web/{auth,api_limiter} + model/validate additions; W5 server/query_sink, web/sse,
|
||||||
|
storage/logger (additive), server/handler (sink rename) + phase7 test compile fixes; W6
|
||||||
|
web/handlers read set + web/metrics; W7 web/handlers mutation set (incl. mutations.zig,
|
||||||
|
upstreams.zig) + server/local_tables + server/handler (local_tables read path; AFTER W5); W8 web/{static,openapi,routes} +
|
||||||
|
openapi.yaml + dist-placeholder + build.zig; W9 app/cli/main; W10 its test file + ci.yml.
|
||||||
|
Orchestrator: src/tests.zig, spec. Within-wave parallel sessions never share a file;
|
||||||
|
handler.zig is touched by W5 then W7, strictly sequenced.
|
||||||
|
|
||||||
|
## Acceptance (milestone complete)
|
||||||
|
|
||||||
|
- [ ] Both suites + cross green; fmt clean; zero err-level lines in integration output.
|
||||||
|
- [ ] Every PLAN:537-550 endpoint except certs/reload (ruling 2) implemented, documented in
|
||||||
|
openapi.yaml, and contract-tested; auth on/off matrix green.
|
||||||
|
- [ ] SSE live stream works end to end with per-IP cap; fanout precedes persistence.
|
||||||
|
- [ ] /metrics scrapes clean (golden test); /api/health rollup correct.
|
||||||
|
- [ ] W9 smoke transcript: boot, API answers, login round trip, SIGTERM 0.
|
||||||
|
- [ ] Placeholder UI loads at /; assets ETag/304/gzip machinery proven.
|
||||||
|
- [ ] Spec As-built synced per session.
|
||||||
|
|
||||||
|
## Review (Codex, As built)
|
||||||
|
|
||||||
|
Round 1: 6 important + 3 minor, all fixed.
|
||||||
|
- local.zig: all six mutation paths hold `config_lock` across write AND rebuild+swap, so
|
||||||
|
LocalTables swaps publish in database-write order (deadlock-checked: no callee takes
|
||||||
|
the lock).
|
||||||
|
- settings/auth/server/app: GET /api/settings reads under `config_lock`. New
|
||||||
|
`auth.LiveHash` (mutex holder on `WebState.live_hash`, generation-checked — see round
|
||||||
|
2): login copies the hash out under a short lock and verifies OUTSIDE it; settings PUT
|
||||||
|
dupes the new hash with gpa BEFORE the transaction, installs + clears sessions after
|
||||||
|
commit; `sessionAuth` gates on `live_hash.enabled()`, so a first password set via PUT
|
||||||
|
locks routes without restart. Boot hash borrows the config arena (owned=false);
|
||||||
|
replacements are gpa-owned, freed on the next install or deinit (app.zig defer /
|
||||||
|
Bench.deinit / Env.destroy).
|
||||||
|
- mutations.zig `checkUpstream`: candidate validated beside a companion enabled upstream
|
||||||
|
(URL collision-avoided), so disabled upstreams are creatable. upstreams.zig
|
||||||
|
`applyUpdate` gained the missing last-enabled guard: disabling the last enabled
|
||||||
|
upstream is 409 (delete path already had it; create needs none).
|
||||||
|
- clients.zig: prefixes canonicalized via platform/address.zig (parse zeroes host bits;
|
||||||
|
RFC 5952 text) before a set-level duplicate check (409, constraint-map ruling) and
|
||||||
|
whole-list validation through the real validate.validate; canonical text is stored.
|
||||||
|
- api_limiter.zig: full table fails closed — `bucketLocked` first evicts a bucket that
|
||||||
|
refills to capacity with sse==0 (behaviorally identical to fresh; sweep's idle window
|
||||||
|
is churn hysteresis, not correctness), else `check` refuses with retry_after and
|
||||||
|
`tryAcquireSse` returns false. `untracked` is now a subset of `refused`.
|
||||||
|
- static.zig: `acceptsGzip` parses every entry per the header grammar (specific gzip
|
||||||
|
beats wildcard; malformed q = entry unusable). Dev mode realpath-checks containment of
|
||||||
|
target AND index fallback (no-follow-on-open would miss symlinked intermediate
|
||||||
|
directories).
|
||||||
|
- web_integration_test.zig: drift guard (a) requires the method key inside the specific
|
||||||
|
path's yaml block; a negative test doctors the yaml (method swap between two paths)
|
||||||
|
and proves the guard bites.
|
||||||
|
|
||||||
|
Round 2: 1 important — login could copy the old hash, race a password-change PUT
|
||||||
|
(install + clearAll), finish argon2 verification, and mint a session with the revoked
|
||||||
|
password. Fixed via a generation counter on LiveHash: applyLogin verifies the copy
|
||||||
|
outside any lock, then `confirmSession` checks the generation and inserts the session
|
||||||
|
digest under LiveHash.mutex (lock order: live_hash → sessions, single direction at every
|
||||||
|
site); a changed generation denies the login.
|
||||||
|
|
||||||
|
Round 3: 2 minors, both fixed. (a) install-then-clearAll had a gap where a legitimate
|
||||||
|
new-password cookie could be minted and then wiped — `install` is replaced by
|
||||||
|
`installAndRevoke(io, gpa, sessions, new_hash)`: swap, generation bump, and nested
|
||||||
|
clearAll in one LiveHash-ordered operation; a confirm either precedes it (wiped) or
|
||||||
|
follows it (stale generation, denied). (b) confirmSession no longer holds LiveHash.mutex
|
||||||
|
across entropy/clock work: token (randomSecure, zeroed on exit) and timestamp are
|
||||||
|
generated before the lock; only the generation check + `createWithToken` digest insert
|
||||||
|
run under the ordered locks. `Sessions.create` removed (createWithToken is the seam).
|
||||||
|
|
||||||
|
Round 4: no findings.
|
||||||
|
|
||||||
|
## Anti-requirements
|
||||||
|
|
||||||
|
- No React/SPA content (milestone 9). No DoH/DoT server or certs/reload (Phase 9). No
|
||||||
|
docs rendering (Phase 10). No WebSocket. No HTTP/2, no TLS on the web port. No external
|
||||||
|
schema validator or yaml parser. No new DB schema migration without an explicit
|
||||||
|
orchestrator ruling (27). No pause persistence. No per-request timeouts. No changes to
|
||||||
|
dns/, filter/matcher, cache, or the DNS pipeline order.
|
||||||
+146
-11
@@ -30,10 +30,13 @@ const Writer = std.Io.Writer;
|
|||||||
const net = std.Io.net;
|
const net = std.Io.net;
|
||||||
const tls = std.crypto.tls;
|
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 bootstrap = @import("config/bootstrap.zig");
|
||||||
const cli = @import("cli.zig");
|
const cli = @import("cli.zig");
|
||||||
const clients = @import("server/clients.zig");
|
const clients = @import("server/clients.zig");
|
||||||
const config_export = @import("config/export.zig");
|
const config_export = @import("config/export.zig");
|
||||||
|
const db = @import("storage/db.zig");
|
||||||
const disk_monitor = @import("storage/disk_monitor.zig");
|
const disk_monitor = @import("storage/disk_monitor.zig");
|
||||||
const dns_cache = @import("cache/dns_cache.zig");
|
const dns_cache = @import("cache/dns_cache.zig");
|
||||||
const doh_client = @import("upstream/doh_client.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 fetcher = @import("filter/fetcher.zig");
|
||||||
const forward_zones = @import("local/forward_zones.zig");
|
const forward_zones = @import("local/forward_zones.zig");
|
||||||
const handler = @import("server/handler.zig");
|
const handler = @import("server/handler.zig");
|
||||||
|
const http_util = @import("web/http_util.zig");
|
||||||
const local_records = @import("local/records.zig");
|
const local_records = @import("local/records.zig");
|
||||||
|
const local_tables = @import("server/local_tables.zig");
|
||||||
const logger_mod = @import("storage/logger.zig");
|
const logger_mod = @import("storage/logger.zig");
|
||||||
const logging = @import("platform/logging.zig");
|
const logging = @import("platform/logging.zig");
|
||||||
const manager_mod = @import("filter/manager.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 model = @import("config/model.zig");
|
||||||
const pause = @import("server/pause.zig");
|
const pause = @import("server/pause.zig");
|
||||||
const pool_mod = @import("upstream/pool.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 rate_limiter = @import("server/rate_limiter.zig");
|
||||||
const retention_mod = @import("storage/retention.zig");
|
const retention_mod = @import("storage/retention.zig");
|
||||||
const shutdown = @import("server/shutdown.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 tcp_server = @import("server/tcp_server.zig");
|
||||||
const transport = @import("upstream/transport.zig");
|
const transport = @import("upstream/transport.zig");
|
||||||
const udp_server = @import("server/udp_server.zig");
|
const udp_server = @import("server/udp_server.zig");
|
||||||
const validate = @import("config/validate.zig");
|
const validate = @import("config/validate.zig");
|
||||||
const version = @import("version.zig");
|
const version = @import("version.zig");
|
||||||
|
const web_server = @import("web/server.zig");
|
||||||
|
|
||||||
const log = std.log.scoped(.nxdns);
|
const log = std.log.scoped(.nxdns);
|
||||||
|
|
||||||
@@ -84,8 +93,8 @@ const ConfigError = error{
|
|||||||
BadRateLimit,
|
BadRateLimit,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn run(runner: cli.Runner, paths: cli.Paths) u8 {
|
pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 {
|
||||||
const code = serve(runner, paths) catch |err| code: {
|
const code = serve(runner, args) catch |err| code: {
|
||||||
runner.err.print("nxdns run failed: {s}\n", .{@errorName(err)}) catch {};
|
runner.err.print("nxdns run failed: {s}\n", .{@errorName(err)}) catch {};
|
||||||
if (isConfigFault(err)) {
|
if (isConfigFault(err)) {
|
||||||
runner.err.writeAll("run `nxdns check` to see the configuration in full\n") catch {};
|
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 io = r.io;
|
||||||
const gpa = r.gpa;
|
const gpa = r.gpa;
|
||||||
|
const paths = args.paths;
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// storage and configuration
|
// 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;
|
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
|
// local answers
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
var records = try local_records.Records.build(gpa, cfg.local_records);
|
// Ruling 12: the tables are published through the holder the API swaps, so
|
||||||
defer records.deinit(gpa);
|
// the holder owns them from here on and frees whichever generation is
|
||||||
|
// current at shutdown.
|
||||||
var zones = try forward_zones.Zones.build(gpa, cfg.forward_zones);
|
var tables: local_tables.LocalTables = .empty;
|
||||||
defer zones.deinit(gpa);
|
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
|
// blocklists
|
||||||
@@ -238,6 +260,20 @@ fn serve(r: cli.Runner, paths: cli.Paths) !u8 {
|
|||||||
defer gpa.free(queue_buf);
|
defer gpa.free(queue_buf);
|
||||||
var query_logger: logger_mod.Logger = .init(cfg.logging, 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)
|
// 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);
|
var tracker_db = try data.openConfigDb(io);
|
||||||
defer tracker_db.close();
|
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
|
// first snapshot
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -302,16 +362,55 @@ fn serve(r: cli.Runner, paths: cli.Paths) !u8 {
|
|||||||
.ecs_mode = cfg.edns.ecs_mode,
|
.ecs_mode = cfg.edns.ecs_mode,
|
||||||
.forward_read_timeout = .{ .raw = model.readTimeout(cfg.upstream), .clock = .awake },
|
.forward_read_timeout = .{ .raw = model.readTimeout(cfg.upstream), .clock = .awake },
|
||||||
.manager = &manager,
|
.manager = &manager,
|
||||||
.records = &records,
|
.local_tables = &tables,
|
||||||
.zones = &zones,
|
|
||||||
.cache = &cache,
|
.cache = &cache,
|
||||||
.negative_ttl_max = cfg.cache.negative_ttl_max,
|
.negative_ttl_max = cfg.cache.negative_ttl_max,
|
||||||
.limiter = &limiter,
|
.limiter = &limiter,
|
||||||
.logger = &query_logger,
|
.sink = &sink,
|
||||||
.pause = &paused,
|
.pause = &paused,
|
||||||
.tracker = &tracker,
|
.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 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;
|
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, clients.Tracker.run, .{ &tracker, io, &tracker_db, gate });
|
||||||
try group.concurrent(io, runMaintenance, .{ &h, io });
|
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, .{
|
logStartup(io, &manager, upstreams.active().len, .{
|
||||||
.udp6 = if (udp6) |*s| s.boundAddress() else null,
|
.udp6 = if (udp6) |*s| s.boundAddress() else null,
|
||||||
.udp4 = if (udp4) |*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;
|
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
|
// background maintenance
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+48
-10
@@ -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 ExportArgs = struct { paths: Paths = .{}, out: ?[]const u8 = null };
|
||||||
pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, force: bool = false };
|
pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, force: bool = false };
|
||||||
|
|
||||||
|
/// `web_dev` is milestone-8 ruling 24's `--web-dev <dir>`: 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) {
|
pub const Command = union(enum) {
|
||||||
run: Paths,
|
run: RunArgs,
|
||||||
check: CheckArgs,
|
check: CheckArgs,
|
||||||
export_: ExportArgs,
|
export_: ExportArgs,
|
||||||
import_: ImportArgs,
|
import_: ImportArgs,
|
||||||
@@ -89,7 +93,7 @@ pub fn parseArgs(argv: []const []const u8) ParseError!Command {
|
|||||||
if (rest.len != 0) return error.TooManyArguments;
|
if (rest.len != 0) return error.TooManyArguments;
|
||||||
return .help;
|
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, "check")) return .{ .check = try parseCheckArgs(rest) };
|
||||||
if (eql(command, "export")) return .{ .export_ = try parseExportArgs(rest) };
|
if (eql(command, "export")) return .{ .export_ = try parseExportArgs(rest) };
|
||||||
if (eql(command, "import")) return .{ .import_ = try parseImportArgs(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.*];
|
return argv[i.*];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `run` and `check` take the same two flags. `run` throws away
|
/// `run` takes `check`'s two flags plus `--web-dev`, which only a process that
|
||||||
/// `config_explicit`; bootstrap reads the path either way.
|
/// 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 {
|
fn parseCheckArgs(argv: []const []const u8) ParseError!CheckArgs {
|
||||||
var args: CheckArgs = .{};
|
var args: CheckArgs = .{};
|
||||||
var i: usize = 0;
|
var i: usize = 0;
|
||||||
@@ -324,6 +344,8 @@ const usage_text =
|
|||||||
\\ --config FILE configuration file (default /etc/nxdns/config.zon)
|
\\ --config FILE configuration file (default /etc/nxdns/config.zon)
|
||||||
\\ --out FILE write the export to FILE instead of stdout
|
\\ --out FILE write the export to FILE instead of stdout
|
||||||
\\ --force let import replace a database that already has content
|
\\ --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`,
|
/// 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
|
/// which is where the composition root belongs; this stays the entry point so
|
||||||
/// that `main` dispatches every command the same way.
|
/// that `main` dispatches every command the same way.
|
||||||
pub fn runRun(r: Runner, paths: Paths) u8 {
|
pub fn runRun(r: Runner, args: RunArgs) u8 {
|
||||||
return app.run(r, paths);
|
return app.run(r, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn runExport(r: Runner, args: ExportArgs) u8 {
|
pub fn runExport(r: Runner, args: ExportArgs) u8 {
|
||||||
@@ -742,14 +764,30 @@ const testing = std.testing;
|
|||||||
|
|
||||||
test "parseArgs accepts run with no flags" {
|
test "parseArgs accepts run with no flags" {
|
||||||
const command = try parseArgs(&.{"run"});
|
const command = try parseArgs(&.{"run"});
|
||||||
try testing.expectEqualStrings("/var/lib/nxdns", command.run.data_dir);
|
try testing.expectEqualStrings("/var/lib/nxdns", command.run.paths.data_dir);
|
||||||
try testing.expectEqualStrings("/etc/nxdns/config.zon", command.run.config);
|
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" {
|
test "parseArgs accepts run with --data-dir and --config" {
|
||||||
const command = try parseArgs(&.{ "run", "--data-dir", "/srv/nx", "--config", "/tmp/c.zon" });
|
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("/srv/nx", command.run.paths.data_dir);
|
||||||
try testing.expectEqualStrings("/tmp/c.zon", command.run.config);
|
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" {
|
test "parseArgs accepts --data-dir with and without an equals sign" {
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ pub const Web = struct {
|
|||||||
password_hash: []const u8 = "",
|
password_hash: []const u8 = "",
|
||||||
session_ttl_hours: u16 = 24,
|
session_ttl_hours: u16 = 24,
|
||||||
api_rate_limit_per_min: u32 = 300,
|
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,
|
sse_max_connections_per_ip: u16 = 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -506,6 +510,7 @@ const expected_keys = [_][]const u8{
|
|||||||
"upstream.connect_timeout_ms",
|
"upstream.connect_timeout_ms",
|
||||||
"upstream.read_timeout_ms",
|
"upstream.read_timeout_ms",
|
||||||
"upstream.total_timeout_ms",
|
"upstream.total_timeout_ms",
|
||||||
|
"web.api_localhost_exempt",
|
||||||
"web.api_rate_limit_per_min",
|
"web.api_rate_limit_per_min",
|
||||||
"web.bind",
|
"web.bind",
|
||||||
"web.enabled",
|
"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",
|
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def",
|
||||||
.session_ttl_hours = 23,
|
.session_ttl_hours = 23,
|
||||||
.api_rate_limit_per_min = 29,
|
.api_rate_limit_per_min = 29,
|
||||||
|
.api_localhost_exempt = false,
|
||||||
.sse_max_connections_per_ip = 31,
|
.sse_max_connections_per_ip = 31,
|
||||||
},
|
},
|
||||||
.doh_server = .{
|
.doh_server = .{
|
||||||
|
|||||||
+1
-1
@@ -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);
|
const command = cli.parseArgs(argv.items) catch |e| return cli.runUsageError(runner, e);
|
||||||
|
|
||||||
return switch (command) {
|
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
|
// `true`: the probe leaves the machine, which is right for an operator
|
||||||
// running `nxdns check` and wrong for a test.
|
// running `nxdns check` and wrong for a test.
|
||||||
.check => |args_| cli.runCheck(runner, args_, true),
|
.check => |args_| cli.runCheck(runner, args_, true),
|
||||||
|
|||||||
+83
-20
@@ -26,6 +26,7 @@ const edns = @import("../dns/edns.zig");
|
|||||||
const forward_client = @import("../local/forward_client.zig");
|
const forward_client = @import("../local/forward_client.zig");
|
||||||
const forward_zones = @import("../local/forward_zones.zig");
|
const forward_zones = @import("../local/forward_zones.zig");
|
||||||
const header = @import("../dns/header.zig");
|
const header = @import("../dns/header.zig");
|
||||||
|
const local_tables_mod = @import("local_tables.zig");
|
||||||
const logger_mod = @import("../storage/logger.zig");
|
const logger_mod = @import("../storage/logger.zig");
|
||||||
const manager = @import("../filter/manager.zig");
|
const manager = @import("../filter/manager.zig");
|
||||||
const matcher = @import("../filter/matcher.zig");
|
const matcher = @import("../filter/matcher.zig");
|
||||||
@@ -33,6 +34,7 @@ const model = @import("../config/model.zig");
|
|||||||
const name = @import("../dns/name.zig");
|
const name = @import("../dns/name.zig");
|
||||||
const packet = @import("../dns/packet.zig");
|
const packet = @import("../dns/packet.zig");
|
||||||
const pause = @import("pause.zig");
|
const pause = @import("pause.zig");
|
||||||
|
const query_sink = @import("query_sink.zig");
|
||||||
const question = @import("../dns/question.zig");
|
const question = @import("../dns/question.zig");
|
||||||
const rate_limiter = @import("rate_limiter.zig");
|
const rate_limiter = @import("rate_limiter.zig");
|
||||||
const record = @import("../dns/record.zig");
|
const record = @import("../dns/record.zig");
|
||||||
@@ -95,6 +97,12 @@ comptime {
|
|||||||
std.debug.assert(max_synthetic_len <= udp_limit_min);
|
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 {
|
pub const Handler = struct {
|
||||||
/// In production this is `pool.client()`.
|
/// In production this is `pool.client()`.
|
||||||
upstream: transport.Client,
|
upstream: transport.Client,
|
||||||
@@ -102,8 +110,10 @@ pub const Handler = struct {
|
|||||||
ecs_mode: model.EcsMode = .strip,
|
ecs_mode: model.EcsMode = .strip,
|
||||||
forward_read_timeout: std.Io.Clock.Duration,
|
forward_read_timeout: std.Io.Clock.Duration,
|
||||||
manager: ?*manager.Manager = null,
|
manager: ?*manager.Manager = null,
|
||||||
records: *const records.Records,
|
/// The published local records and forward zones (milestone-8 ruling 12).
|
||||||
zones: *const forward_zones.Zones,
|
/// 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: ?*dns_cache.DnsCache = null,
|
||||||
cache_mutex: std.Io.Mutex = .init,
|
cache_mutex: std.Io.Mutex = .init,
|
||||||
/// `cfg.cache.negative_ttl_max`. `DnsCache` keeps no copy of its config and
|
/// `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,
|
negative_ttl_max: u32 = 0,
|
||||||
limiter: ?*rate_limiter.RateLimiter = null,
|
limiter: ?*rate_limiter.RateLimiter = null,
|
||||||
limiter_mutex: std.Io.Mutex = .init,
|
limiter_mutex: std.Io.Mutex = .init,
|
||||||
logger: ?*logger_mod.Logger = null,
|
sink: ?*query_sink.QuerySink = null,
|
||||||
pause: ?*pause.Pause = null,
|
pause: ?*pause.Pause = null,
|
||||||
tracker: ?*clients.Tracker = null,
|
tracker: ?*clients.Tracker = null,
|
||||||
stats: Stats = .{},
|
stats: Stats = .{},
|
||||||
@@ -255,6 +265,12 @@ pub const Handler = struct {
|
|||||||
const snapshot: ?*const matcher.Snapshot = if (acquired) |a| a.snapshot else null;
|
const snapshot: ?*const matcher.Snapshot = if (acquired) |a| a.snapshot else null;
|
||||||
if (snapshot == null) bump(&self.stats.unfiltered_queries);
|
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 = .{
|
var ctx: Context = .{
|
||||||
.handler = self,
|
.handler = self,
|
||||||
.io = io,
|
.io = io,
|
||||||
@@ -271,6 +287,8 @@ pub const Handler = struct {
|
|||||||
.started = started,
|
.started = started,
|
||||||
.now_s = started.toSeconds(),
|
.now_s = started.toSeconds(),
|
||||||
.snapshot = snapshot,
|
.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,
|
.group = if (snapshot) |s| s.groupForClient(from) else 0,
|
||||||
.domain = matcher.normalize(q.name, &scratch.normalize),
|
.domain = matcher.normalize(q.name, &scratch.normalize),
|
||||||
};
|
};
|
||||||
@@ -319,6 +337,10 @@ const Context = struct {
|
|||||||
started: std.Io.Timestamp,
|
started: std.Io.Timestamp,
|
||||||
now_s: i64,
|
now_s: i64,
|
||||||
snapshot: ?*const matcher.Snapshot,
|
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,
|
group: u32,
|
||||||
/// The queried name, normalized into `scratch.normalize`.
|
/// The queried name, normalized into `scratch.normalize`.
|
||||||
domain: []const u8,
|
domain: []const u8,
|
||||||
@@ -328,8 +350,8 @@ const Context = struct {
|
|||||||
/// blocklist.
|
/// blocklist.
|
||||||
fn run(ctx: *Context) Handler.Outcome {
|
fn run(ctx: *Context) Handler.Outcome {
|
||||||
if (ctx.q.qclass != .in) return ctx.viaUpstream(.{ .filter = false, .cache = false });
|
if (ctx.q.qclass != .in) return ctx.viaUpstream(.{ .filter = false, .cache = false });
|
||||||
if (ctx.handler.records.hasName(ctx.domain)) return ctx.viaLocal();
|
if (ctx.records.hasName(ctx.domain)) return ctx.viaLocal();
|
||||||
if (ctx.handler.zones.match(ctx.domain)) |zone| return ctx.viaForwardZone(zone);
|
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;
|
const paused = if (ctx.handler.pause) |p| p.isPaused(ctx.now_s) else false;
|
||||||
if (paused) bump(&ctx.handler.stats.paused_queries);
|
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
|
/// A local CNAME is returned as it stands (ruling 12). The client re-queries
|
||||||
/// the target, and that query runs the whole pipeline.
|
/// the target, and that query runs the whole pipeline.
|
||||||
fn viaLocal(ctx: *Context) Handler.Outcome {
|
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
|
var b = packet.ResponseBuilder.init(ctx.response_buf, ctx.hdr, ctx.q) catch
|
||||||
return ctx.servFail();
|
return ctx.servFail();
|
||||||
@@ -489,14 +511,14 @@ const Context = struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn log(ctx: *Context, fields: LogFields) void {
|
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 ip_buf: [max_ip_text]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&ip_buf);
|
var w: std.Io.Writer = .fixed(&ip_buf);
|
||||||
ctx.from.format(&w) catch unreachable;
|
ctx.from.format(&w) catch unreachable;
|
||||||
|
|
||||||
const now = std.Io.Clock.real.now(ctx.io);
|
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,
|
.timestamp = ctx.now_s,
|
||||||
.domain = ctx.domain,
|
.domain = ctx.domain,
|
||||||
.client_ip = w.buffered(),
|
.client_ip = w.buffered(),
|
||||||
@@ -861,6 +883,8 @@ fn bump(counter: *std.atomic.Value(u64)) void {
|
|||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const sse = @import("../web/sse.zig");
|
||||||
|
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
||||||
/// Every test needs a real `std.Io`: the handler reads the clock on every query
|
/// 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 blocking: response.Options = .{ .mode = .zero, .ttl = 5 };
|
||||||
const forward_timeout: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(50), .clock = .awake };
|
const forward_timeout: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(50), .clock = .awake };
|
||||||
const client_ip: address.NetAddress = .{ .ip4 = .{ 192, 168, 1, 50 } };
|
const client_ip: address.NetAddress = .{ .ip4 = .{ 192, 168, 1, 50 } };
|
||||||
@@ -894,8 +916,6 @@ fn bare(client: transport.Client) Handler {
|
|||||||
.upstream = client,
|
.upstream = client,
|
||||||
.blocking = blocking,
|
.blocking = blocking,
|
||||||
.forward_read_timeout = forward_timeout,
|
.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 fake: FakeUpstream = .{ .reply = response_bytes };
|
||||||
var h = bare(fake.client());
|
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;
|
var query_buf: [512]u8 = undefined;
|
||||||
const query = queryFor(&query_buf, 0x1234, "nas.lan", .a, .in);
|
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 fake: FakeUpstream = .{ .reply = response_bytes };
|
||||||
var h = bare(fake.client());
|
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;
|
var query_buf: [512]u8 = undefined;
|
||||||
const query = queryFor(&query_buf, 0x1234, "nas.lan", .aaaa, .in);
|
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 fake: FakeUpstream = .{ .reply = response_bytes };
|
||||||
var h = bare(fake.client());
|
var h = bare(fake.client());
|
||||||
h.zones = &zones;
|
var tables: local_tables_mod.LocalTables = .{ .zones = zones };
|
||||||
|
h.local_tables = &tables;
|
||||||
h.cache = &cache;
|
h.cache = &cache;
|
||||||
h.negative_ttl_max = 3600;
|
h.negative_ttl_max = 3600;
|
||||||
h.manager = &mgr;
|
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 fake: FakeUpstream = .{ .reply = response_bytes };
|
||||||
var h = bare(fake.client());
|
var h = bare(fake.client());
|
||||||
h.zones = &zones;
|
var tables: local_tables_mod.LocalTables = .{ .zones = zones };
|
||||||
|
h.local_tables = &tables;
|
||||||
h.manager = &mgr;
|
h.manager = &mgr;
|
||||||
|
|
||||||
var query_buf: [512]u8 = undefined;
|
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 queue_buf: [8]logger_mod.Entry = undefined;
|
||||||
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||||
|
var sink: query_sink.QuerySink = .init(&lg, null);
|
||||||
|
|
||||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||||
var h = bare(fake.client());
|
var h = bare(fake.client());
|
||||||
h.manager = &mgr;
|
h.manager = &mgr;
|
||||||
h.records = &table;
|
var tables: local_tables_mod.LocalTables = .{ .records = table };
|
||||||
|
h.local_tables = &tables;
|
||||||
h.cache = &cache;
|
h.cache = &cache;
|
||||||
h.negative_ttl_max = 3600;
|
h.negative_ttl_max = 3600;
|
||||||
h.logger = ≶
|
h.sink = &sink;
|
||||||
|
|
||||||
var buf: [udp_limit_min]u8 = undefined;
|
var buf: [udp_limit_min]u8 = undefined;
|
||||||
var query_buf: [512]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 queue_buf: [4]logger_mod.Entry = undefined;
|
||||||
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||||
|
var sink: query_sink.QuerySink = .init(&lg, null);
|
||||||
|
|
||||||
var fake: FakeUpstream = .{ .reply = chain };
|
var fake: FakeUpstream = .{ .reply = chain };
|
||||||
var h = bare(fake.client());
|
var h = bare(fake.client());
|
||||||
h.manager = &mgr;
|
h.manager = &mgr;
|
||||||
h.logger = ≶
|
h.sink = &sink;
|
||||||
|
|
||||||
var buf: [udp_limit_min]u8 = undefined;
|
var buf: [udp_limit_min]u8 = undefined;
|
||||||
_ = try expectReply(udp(&h, t.io(), query_bytes, &buf));
|
_ = 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());
|
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" {
|
test "a refused query is counted and never logged" {
|
||||||
var t: TestIo = .init();
|
var t: TestIo = .init();
|
||||||
defer t.deinit();
|
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 queue_buf: [4]logger_mod.Entry = undefined;
|
||||||
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||||
|
var sink: query_sink.QuerySink = .init(&lg, null);
|
||||||
|
|
||||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||||
var h = bare(fake.client());
|
var h = bare(fake.client());
|
||||||
h.limiter = &limiter;
|
h.limiter = &limiter;
|
||||||
h.logger = ≶
|
h.sink = &sink;
|
||||||
|
|
||||||
var buf: [udp_limit_min]u8 = undefined;
|
var buf: [udp_limit_min]u8 = undefined;
|
||||||
_ = try expectReply(udp(&h, t.io(), query_bytes, &buf));
|
_ = try expectReply(udp(&h, t.io(), query_bytes, &buf));
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ const dns_cache = @import("../cache/dns_cache.zig");
|
|||||||
const forward_zones = @import("../local/forward_zones.zig");
|
const forward_zones = @import("../local/forward_zones.zig");
|
||||||
const handler = @import("handler.zig");
|
const handler = @import("handler.zig");
|
||||||
const header = @import("../dns/header.zig");
|
const header = @import("../dns/header.zig");
|
||||||
|
const local_tables = @import("local_tables.zig");
|
||||||
const logger_mod = @import("../storage/logger.zig");
|
const logger_mod = @import("../storage/logger.zig");
|
||||||
const manager = @import("../filter/manager.zig");
|
const manager = @import("../filter/manager.zig");
|
||||||
const matcher = @import("../filter/matcher.zig");
|
const matcher = @import("../filter/matcher.zig");
|
||||||
@@ -39,6 +40,7 @@ const model = @import("../config/model.zig");
|
|||||||
const name = @import("../dns/name.zig");
|
const name = @import("../dns/name.zig");
|
||||||
const packet = @import("../dns/packet.zig");
|
const packet = @import("../dns/packet.zig");
|
||||||
const pause = @import("pause.zig");
|
const pause = @import("pause.zig");
|
||||||
|
const query_sink = @import("query_sink.zig");
|
||||||
const question = @import("../dns/question.zig");
|
const question = @import("../dns/question.zig");
|
||||||
const rate_limiter = @import("rate_limiter.zig");
|
const rate_limiter = @import("rate_limiter.zig");
|
||||||
const record = @import("../dns/record.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.
|
/// enough that a broken server fails the run instead of hanging it.
|
||||||
const budget: std.Io.Timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } };
|
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
|
/// A five-second TTL makes the blocking answer's TTL unmistakable next to the
|
||||||
/// upstream's 300.
|
/// upstream's 300.
|
||||||
const blocking: response.Options = .{ .mode = .zero, .ttl = 5 };
|
const blocking: response.Options = .{ .mode = .zero, .ttl = 5 };
|
||||||
@@ -84,8 +83,6 @@ fn baseHandler(client: transport.Client) handler.Handler {
|
|||||||
.upstream = client,
|
.upstream = client,
|
||||||
.blocking = blocking,
|
.blocking = blocking,
|
||||||
.forward_read_timeout = forward_timeout,
|
.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 queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||||
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||||
|
var sink: query_sink.QuerySink = .init(&lg, null);
|
||||||
|
|
||||||
var fake: FakeUpstream = .{ .reply = .a };
|
var fake: FakeUpstream = .{ .reply = .a };
|
||||||
var h = baseHandler(fake.client());
|
var h = baseHandler(fake.client());
|
||||||
h.manager = &mgr;
|
h.manager = &mgr;
|
||||||
h.logger = ≶
|
h.sink = &sink;
|
||||||
|
|
||||||
var loop = try Loop.bind(gpa, io, &h);
|
var loop = try Loop.bind(gpa, io, &h);
|
||||||
defer loop.stop(gpa, io);
|
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 fake: FakeUpstream = .{ .reply = .a };
|
||||||
var h = baseHandler(fake.client());
|
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);
|
var loop = try Loop.bind(gpa, io, &h);
|
||||||
defer loop.stop(gpa, io);
|
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 fake: FakeUpstream = .{ .reply = .a };
|
||||||
var h = baseHandler(fake.client());
|
var h = baseHandler(fake.client());
|
||||||
h.manager = &mgr;
|
h.manager = &mgr;
|
||||||
h.zones = &zones;
|
var tables: local_tables.LocalTables = .{ .zones = zones };
|
||||||
|
h.local_tables = &tables;
|
||||||
h.cache = &cache;
|
h.cache = &cache;
|
||||||
h.negative_ttl_max = 3600;
|
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 queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||||
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||||
|
var sink: query_sink.QuerySink = .init(&lg, null);
|
||||||
|
|
||||||
var fake: FakeUpstream = .{ .reply = .a };
|
var fake: FakeUpstream = .{ .reply = .a };
|
||||||
var h = baseHandler(fake.client());
|
var h = baseHandler(fake.client());
|
||||||
h.cache = &cache;
|
h.cache = &cache;
|
||||||
h.negative_ttl_max = 3600;
|
h.negative_ttl_max = 3600;
|
||||||
h.logger = ≶
|
h.sink = &sink;
|
||||||
|
|
||||||
var loop = try Loop.bind(gpa, io, &h);
|
var loop = try Loop.bind(gpa, io, &h);
|
||||||
defer loop.stop(gpa, io);
|
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 queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||||
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
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 fake: FakeUpstream = .{ .reply = .{ .cname = "tracker.example.org" } };
|
||||||
var h = baseHandler(fake.client());
|
var h = baseHandler(fake.client());
|
||||||
h.manager = &mgr;
|
h.manager = &mgr;
|
||||||
h.logger = ≶
|
h.sink = &sink;
|
||||||
|
|
||||||
var loop = try Loop.bind(gpa, io, &h);
|
var loop = try Loop.bind(gpa, io, &h);
|
||||||
defer loop.stop(gpa, io);
|
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 queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||||
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||||
|
var sink: query_sink.QuerySink = .init(&lg, null);
|
||||||
|
|
||||||
var fake: FakeUpstream = .{ .reply = .a };
|
var fake: FakeUpstream = .{ .reply = .a };
|
||||||
var h = baseHandler(fake.client());
|
var h = baseHandler(fake.client());
|
||||||
h.limiter = &limiter;
|
h.limiter = &limiter;
|
||||||
h.logger = ≶
|
h.sink = &sink;
|
||||||
|
|
||||||
var loop = try Loop.bind(gpa, io, &h);
|
var loop = try Loop.bind(gpa, io, &h);
|
||||||
defer loop.stop(gpa, io);
|
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();
|
shutdown.reset();
|
||||||
defer 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,
|
.data_dir = root,
|
||||||
.config = config_path,
|
.config = config_path,
|
||||||
} });
|
} } });
|
||||||
|
|
||||||
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||||
const client = try client_address.bind(test_io, .{ .mode = .dgram });
|
const client = try client_address.bind(test_io, .{ .mode = .dgram });
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -20,8 +20,6 @@ const tcp_server = @import("tcp_server.zig");
|
|||||||
const udp_server = @import("udp_server.zig");
|
const udp_server = @import("udp_server.zig");
|
||||||
const model = @import("../config/model.zig");
|
const model = @import("../config/model.zig");
|
||||||
const response = @import("../filter/response.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 packet = @import("../dns/packet.zig");
|
||||||
const record = @import("../dns/record.zig");
|
const record = @import("../dns/record.zig");
|
||||||
const types = @import("../dns/types.zig");
|
const types = @import("../dns/types.zig");
|
||||||
@@ -31,8 +29,6 @@ const transport = @import("../upstream/transport.zig");
|
|||||||
|
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
||||||
const empty_records: records.Records = .empty;
|
|
||||||
const empty_zones: forward_zones.Zones = .empty;
|
|
||||||
const blocking_defaults: model.Blocking = .{};
|
const blocking_defaults: model.Blocking = .{};
|
||||||
const blocking: response.Options = .{
|
const blocking: response.Options = .{
|
||||||
.mode = blocking_defaults.response,
|
.mode = blocking_defaults.response,
|
||||||
@@ -51,8 +47,6 @@ fn bareHandler(client: transport.Client) handler.Handler {
|
|||||||
.upstream = client,
|
.upstream = client,
|
||||||
.blocking = blocking,
|
.blocking = blocking,
|
||||||
.forward_read_timeout = forward_timeout,
|
.forward_read_timeout = forward_timeout,
|
||||||
.records = &empty_records,
|
|
||||||
.zones = &empty_zones,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ const handler = @import("handler.zig");
|
|||||||
const tcp_server = @import("tcp_server.zig");
|
const tcp_server = @import("tcp_server.zig");
|
||||||
const model = @import("../config/model.zig");
|
const model = @import("../config/model.zig");
|
||||||
const response = @import("../filter/response.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 header = @import("../dns/header.zig");
|
||||||
const packet = @import("../dns/packet.zig");
|
const packet = @import("../dns/packet.zig");
|
||||||
const types = @import("../dns/types.zig");
|
const types = @import("../dns/types.zig");
|
||||||
@@ -26,8 +24,6 @@ const transport = @import("../upstream/transport.zig");
|
|||||||
|
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
||||||
const empty_records: records.Records = .empty;
|
|
||||||
const empty_zones: forward_zones.Zones = .empty;
|
|
||||||
const blocking_defaults: model.Blocking = .{};
|
const blocking_defaults: model.Blocking = .{};
|
||||||
const blocking: response.Options = .{
|
const blocking: response.Options = .{
|
||||||
.mode = blocking_defaults.response,
|
.mode = blocking_defaults.response,
|
||||||
@@ -46,8 +42,6 @@ fn bareHandler(client: transport.Client) handler.Handler {
|
|||||||
.upstream = client,
|
.upstream = client,
|
||||||
.blocking = blocking,
|
.blocking = blocking,
|
||||||
.forward_read_timeout = forward_timeout,
|
.forward_read_timeout = forward_timeout,
|
||||||
.records = &empty_records,
|
|
||||||
.zones = &empty_zones,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ const handler = @import("handler.zig");
|
|||||||
const udp_server = @import("udp_server.zig");
|
const udp_server = @import("udp_server.zig");
|
||||||
const model = @import("../config/model.zig");
|
const model = @import("../config/model.zig");
|
||||||
const response = @import("../filter/response.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 header = @import("../dns/header.zig");
|
||||||
const packet = @import("../dns/packet.zig");
|
const packet = @import("../dns/packet.zig");
|
||||||
const types = @import("../dns/types.zig");
|
const types = @import("../dns/types.zig");
|
||||||
@@ -25,8 +23,6 @@ const transport = @import("../upstream/transport.zig");
|
|||||||
|
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
||||||
const empty_records: records.Records = .empty;
|
|
||||||
const empty_zones: forward_zones.Zones = .empty;
|
|
||||||
const blocking_defaults: model.Blocking = .{};
|
const blocking_defaults: model.Blocking = .{};
|
||||||
const blocking: response.Options = .{
|
const blocking: response.Options = .{
|
||||||
.mode = blocking_defaults.response,
|
.mode = blocking_defaults.response,
|
||||||
@@ -45,8 +41,6 @@ fn bareHandler(client: transport.Client) handler.Handler {
|
|||||||
.upstream = client,
|
.upstream = client,
|
||||||
.blocking = blocking,
|
.blocking = blocking,
|
||||||
.forward_read_timeout = forward_timeout,
|
.forward_read_timeout = forward_timeout,
|
||||||
.records = &empty_records,
|
|
||||||
.zones = &empty_zones,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+52
-7
@@ -7,9 +7,11 @@
|
|||||||
//! the moment the query finishes. That is the whole reason this file has fixed
|
//! the moment the query finishes. That is the whole reason this file has fixed
|
||||||
//! buffers instead of slices.
|
//! buffers instead of slices.
|
||||||
//!
|
//!
|
||||||
//! The privacy transforms of §11.4 run inside `log`, before the entry is
|
//! The privacy transforms of §11.4 run in `transformed`, before the entry is
|
||||||
//! enqueued, so nothing downstream — the database now, Phase 8's event stream
|
//! enqueued, so nothing downstream — the database or the event stream — can
|
||||||
//! later — can observe a value the operator asked to hide.
|
//! 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
|
//! 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
|
//! 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
|
/// Applies the privacy transforms and enqueues without ever blocking the
|
||||||
/// query path. A full queue loses its oldest unflushed entry (§11.4).
|
/// query path. A full queue loses its oldest unflushed entry (§11.4).
|
||||||
pub fn log(self: *Logger, io: std.Io, entry: Entry) void {
|
pub fn log(self: *Logger, io: std.Io, entry: Entry) void {
|
||||||
var transformed = entry;
|
self.logTransformed(io, self.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);
|
/// 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
|
/// 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());
|
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" {
|
test "a full queue drops the oldest entry and counts it" {
|
||||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||||
defer threaded.deinit();
|
defer threaded.deinit();
|
||||||
|
|||||||
@@ -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 });
|
var pass: retention.Retention = .init(.{ .retention_days = 30 });
|
||||||
pass.runOnce(io, log_db.database());
|
pass.runOnce(io, log_db.database());
|
||||||
|
|
||||||
try testing.expectEqual(@as(u64, 1), pass.stats.passes);
|
try testing.expectEqual(@as(u64, 1), pass.snapshotStats().passes);
|
||||||
try testing.expectEqual(@as(u64, 2), pass.stats.rows_pruned);
|
try testing.expectEqual(@as(u64, 2), pass.snapshotStats().rows_pruned);
|
||||||
try testing.expectEqual(@as(u64, 1), pass.stats.checkpoints);
|
try testing.expectEqual(@as(u64, 1), pass.snapshotStats().checkpoints);
|
||||||
try testing.expectEqual(@as(u64, 0), pass.stats.vacuums);
|
try testing.expectEqual(@as(u64, 0), pass.snapshotStats().vacuums);
|
||||||
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(log_db.database()));
|
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(log_db.database()));
|
||||||
// Both names stay: the dimension table is not collected.
|
// Both names stay: the dimension table is not collected.
|
||||||
try testing.expectEqual(@as(i64, 2), try queries_repo.countDomains(log_db.database()));
|
try testing.expectEqual(@as(i64, 2), try queries_repo.countDomains(log_db.database()));
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
//! not appear in an export. `countClients` counts **all** rows, because S5's
|
//! not appear in an export. `countClients` counts **all** rows, because S5's
|
||||||
//! "has this database ever been configured" predicate needs the true count.
|
//! "has this database ever been configured" predicate needs the true count.
|
||||||
//!
|
//!
|
||||||
//! Only list / insert / deleteAll / count, plus the two runtime calls
|
//! The import path is list / insert / deleteAll / count, plus the two runtime
|
||||||
//! `upsertSeen` and `pruneStale` that the Phase 7 client tracker owns.
|
//! 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 std = @import("std");
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
@@ -15,6 +17,7 @@ const db = @import("../db.zig");
|
|||||||
const migrations = @import("../migrations.zig");
|
const migrations = @import("../migrations.zig");
|
||||||
const model = @import("../../config/model.zig");
|
const model = @import("../../config/model.zig");
|
||||||
const context = @import("context.zig");
|
const context = @import("context.zig");
|
||||||
|
const crud = @import("crud.zig");
|
||||||
|
|
||||||
const IdMap = context.IdMap;
|
const IdMap = context.IdMap;
|
||||||
const InsertContext = context.InsertContext;
|
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");
|
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
|
// tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -498,3 +750,187 @@ test "listClientPrefixes is leak-safe under allocation failure" {
|
|||||||
try ids.put(testing.allocator, "kids", 2);
|
try ids.put(testing.allocator, "kids", 2);
|
||||||
try testing.checkAllAllocationFailures(testing.allocator, listClientPrefixesUnderFailure, .{&ids});
|
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});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
//! stable across an import, so an export carrying them would not re-import into
|
//! stable across an import, so an export carrying them would not re-import into
|
||||||
//! the same shape.
|
//! the same shape.
|
||||||
//!
|
//!
|
||||||
//! Only list / insert / deleteAll / count exist. Update-by-id, delete-by-id and
|
//! The import path is list / insert / deleteAll / count. Phase 8's REST surface
|
||||||
//! paged reads are Phase 8's REST surface; adding them now would be untested,
|
//! is the second half of this file: it speaks row ids, because that is what a
|
||||||
//! unused generality.
|
//! `/api/groups/{id}` request names.
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
@@ -15,6 +15,7 @@ const db = @import("../db.zig");
|
|||||||
const migrations = @import("../migrations.zig");
|
const migrations = @import("../migrations.zig");
|
||||||
const model = @import("../../config/model.zig");
|
const model = @import("../../config/model.zig");
|
||||||
const context = @import("context.zig");
|
const context = @import("context.zig");
|
||||||
|
const crud = @import("crud.zig");
|
||||||
|
|
||||||
const IdMap = context.IdMap;
|
const IdMap = context.IdMap;
|
||||||
const InsertContext = context.InsertContext;
|
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");
|
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
|
// tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -284,3 +427,207 @@ test "listGroupSources is leak-safe under allocation failure" {
|
|||||||
defer ids.deinit(testing.allocator);
|
defer ids.deinit(testing.allocator);
|
||||||
try testing.checkAllAllocationFailures(testing.allocator, listGroupSourcesUnderFailure, .{&ids});
|
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, .{});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
//! `local_records` and `forward_zones`.
|
//! `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 std = @import("std");
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
@@ -9,6 +11,7 @@ const db = @import("../db.zig");
|
|||||||
const migrations = @import("../migrations.zig");
|
const migrations = @import("../migrations.zig");
|
||||||
const model = @import("../../config/model.zig");
|
const model = @import("../../config/model.zig");
|
||||||
const context = @import("context.zig");
|
const context = @import("context.zig");
|
||||||
|
const crud = @import("crud.zig");
|
||||||
|
|
||||||
const InsertContext = context.InsertContext;
|
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");
|
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
|
// tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -253,3 +435,161 @@ fn listForwardZonesUnderFailure(gpa: Allocator) !void {
|
|||||||
test "listForwardZones is leak-safe under allocation failure" {
|
test "listForwardZones is leak-safe under allocation failure" {
|
||||||
try testing.checkAllAllocationFailures(testing.allocator, listForwardZonesUnderFailure, .{});
|
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, .{});
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
//!
|
//!
|
||||||
//! Two shapes live here. The free functions follow the milestone-4 repository
|
//! Two shapes live here. The free functions follow the milestone-4 repository
|
||||||
//! idiom — prepare, use, finalize — because retention runs them a handful of
|
//! 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
|
//! times per day, and the API read layer at the bottom of the file runs once
|
||||||
//! `BatchWriter`, which owns its three statements for its whole life
|
//! 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
|
//! (`db.zig:360` names this file as the reason `db.zig` carries no statement
|
||||||
//! cache).
|
//! cache).
|
||||||
//!
|
//!
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
//! decides what a failed batch means.
|
//! decides what a failed batch means.
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
const db = @import("../db.zig");
|
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");
|
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
|
// 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, 1), try countRows(&database));
|
||||||
try testing.expectEqual(@as(i64, 2), try countDomains(&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, ""));
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,9 @@
|
|||||||
//! order as a whole is: `import` inserts the rules in export order, so the new
|
//! order as a whole is: `import` inserts the rules in export order, so the new
|
||||||
//! ids ascend in exactly the order this statement produced.
|
//! ids ascend in exactly the order this statement produced.
|
||||||
//!
|
//!
|
||||||
//! 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 std = @import("std");
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
@@ -22,6 +24,7 @@ const migrations = @import("../migrations.zig");
|
|||||||
const model = @import("../../config/model.zig");
|
const model = @import("../../config/model.zig");
|
||||||
const context = @import("context.zig");
|
const context = @import("context.zig");
|
||||||
const groups_repo = @import("groups_repo.zig");
|
const groups_repo = @import("groups_repo.zig");
|
||||||
|
const crud = @import("crud.zig");
|
||||||
|
|
||||||
const IdMap = context.IdMap;
|
const IdMap = context.IdMap;
|
||||||
const InsertContext = context.InsertContext;
|
const InsertContext = context.InsertContext;
|
||||||
@@ -89,6 +92,145 @@ pub fn countRules(database: *db.Db) db.Error!i64 {
|
|||||||
return database.queryInt("SELECT count(*) FROM rules");
|
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
|
// tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -292,3 +434,100 @@ test "listRules is leak-safe under allocation failure" {
|
|||||||
defer ids.deinit(testing.allocator);
|
defer ids.deinit(testing.allocator);
|
||||||
try testing.checkAllAllocationFailures(testing.allocator, listRulesUnderFailure, .{&ids});
|
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});
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
//! `model.fromSettings` speak, so the scalar sections cross the storage boundary
|
//! `model.fromSettings` speak, so the scalar sections cross the storage boundary
|
||||||
//! without a second shape.
|
//! 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 std = @import("std");
|
||||||
const Allocator = std.mem.Allocator;
|
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");
|
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
|
// tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -143,3 +171,41 @@ fn listSettingsUnderFailure(gpa: Allocator) !void {
|
|||||||
test "listSettings is leak-safe under allocation failure" {
|
test "listSettings is leak-safe under allocation failure" {
|
||||||
try testing.checkAllAllocationFailures(testing.allocator, listSettingsUnderFailure, .{});
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
//! facts a running server produces; an insert leaves them at their column
|
//! facts a running server produces; an insert leaves them at their column
|
||||||
//! defaults so two exports taken minutes apart stay identical.
|
//! 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 std = @import("std");
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
@@ -14,6 +15,7 @@ const db = @import("../db.zig");
|
|||||||
const migrations = @import("../migrations.zig");
|
const migrations = @import("../migrations.zig");
|
||||||
const model = @import("../../config/model.zig");
|
const model = @import("../../config/model.zig");
|
||||||
const context = @import("context.zig");
|
const context = @import("context.zig");
|
||||||
|
const crud = @import("crud.zig");
|
||||||
|
|
||||||
const InsertContext = context.InsertContext;
|
const InsertContext = context.InsertContext;
|
||||||
|
|
||||||
@@ -91,6 +93,9 @@ pub const SourceRow = struct {
|
|||||||
url: []const u8,
|
url: []const u8,
|
||||||
name: []const u8,
|
name: []const u8,
|
||||||
enabled: bool,
|
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,
|
last_updated: ?i64,
|
||||||
domain_count: i64,
|
domain_count: i64,
|
||||||
wildcard_count: i64,
|
wildcard_count: i64,
|
||||||
@@ -107,12 +112,15 @@ pub const SourceStats = struct {
|
|||||||
checksum: []const u8,
|
checksum: []const u8,
|
||||||
};
|
};
|
||||||
|
|
||||||
const list_rows_sql =
|
const row_columns_sql =
|
||||||
\\SELECT id, url, name, enabled, last_updated,
|
\\SELECT id, url, name, enabled, last_updated,
|
||||||
\\ domain_count, wildcard_count, skipped_regex_count, checksum
|
\\ domain_count, wildcard_count, skipped_regex_count, checksum,
|
||||||
\\ FROM blocklist_sources ORDER BY url
|
\\ 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`
|
/// 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
|
/// order `listBlocklistSources` uses. Every string is a heap copy owned by
|
||||||
/// `gpa`; free the whole list with `freeSourceRows` and then `deinit` the list.
|
/// `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);
|
errdefer freeSourceRows(gpa, out.items);
|
||||||
|
|
||||||
while (try stmt.step()) {
|
while (try stmt.step()) {
|
||||||
|
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);
|
const url = try stmt.columnTextAlloc(gpa, 1);
|
||||||
errdefer gpa.free(url);
|
errdefer gpa.free(url);
|
||||||
const name = try stmt.columnTextAlloc(gpa, 2);
|
const name = try stmt.columnTextAlloc(gpa, 2);
|
||||||
errdefer gpa.free(name);
|
errdefer gpa.free(name);
|
||||||
const checksum = try stmt.columnTextAllocOrNull(gpa, 8);
|
const checksum = try stmt.columnTextAllocOrNull(gpa, 8);
|
||||||
errdefer if (checksum) |value| gpa.free(value);
|
errdefer if (checksum) |value| gpa.free(value);
|
||||||
try out.append(gpa, .{
|
return .{
|
||||||
.id = stmt.columnInt(0),
|
.id = stmt.columnInt(0),
|
||||||
.url = url,
|
.url = url,
|
||||||
.name = name,
|
.name = name,
|
||||||
.enabled = stmt.columnBool(3),
|
.enabled = stmt.columnBool(3),
|
||||||
|
.is_suggested = stmt.columnBool(9),
|
||||||
.last_updated = if (stmt.isNull(4)) null else stmt.columnInt(4),
|
.last_updated = if (stmt.isNull(4)) null else stmt.columnInt(4),
|
||||||
.domain_count = stmt.columnInt(5),
|
.domain_count = stmt.columnInt(5),
|
||||||
.wildcard_count = stmt.columnInt(6),
|
.wildcard_count = stmt.columnInt(6),
|
||||||
.skipped_regex_count = stmt.columnInt(7),
|
.skipped_regex_count = stmt.columnInt(7),
|
||||||
.checksum = checksum,
|
.checksum = checksum,
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
return out;
|
|
||||||
|
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 {
|
pub fn freeSourceRows(gpa: Allocator, items: []const SourceRow) void {
|
||||||
for (items) |item| {
|
for (items) |item| freeSourceRow(gpa, item);
|
||||||
gpa.free(item.url);
|
|
||||||
gpa.free(item.name);
|
|
||||||
if (item.checksum) |value| gpa.free(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const update_stats_sql =
|
const update_stats_sql =
|
||||||
@@ -177,6 +194,71 @@ pub fn updateSourceStats(database: *db.Db, id: i64, stats: SourceStats) db.Error
|
|||||||
try stmt.exec();
|
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
|
// tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -361,3 +443,126 @@ fn listSourceRowsUnderFailure(gpa: Allocator) !void {
|
|||||||
test "listSourceRows is leak-safe under allocation failure" {
|
test "listSourceRows is leak-safe under allocation failure" {
|
||||||
try testing.checkAllAllocationFailures(testing.allocator, listSourceRowsUnderFailure, .{});
|
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, .{});
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
//! meaningful order — it matches what `Pool.init` expects — and `url` breaks
|
//! meaningful order — it matches what `Pool.init` expects — and `url` breaks
|
||||||
//! ties uniquely, which is what makes an export byte-stable.
|
//! ties uniquely, which is what makes an export byte-stable.
|
||||||
//!
|
//!
|
||||||
//! 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 std = @import("std");
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
@@ -13,6 +15,7 @@ const db = @import("../db.zig");
|
|||||||
const migrations = @import("../migrations.zig");
|
const migrations = @import("../migrations.zig");
|
||||||
const model = @import("../../config/model.zig");
|
const model = @import("../../config/model.zig");
|
||||||
const context = @import("context.zig");
|
const context = @import("context.zig");
|
||||||
|
const crud = @import("crud.zig");
|
||||||
|
|
||||||
const InsertContext = context.InsertContext;
|
const InsertContext = context.InsertContext;
|
||||||
|
|
||||||
@@ -73,6 +76,118 @@ pub fn countUpstreams(database: *db.Db) db.Error!i64 {
|
|||||||
return database.queryInt("SELECT count(*) FROM upstreams");
|
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
|
// tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -151,3 +266,89 @@ fn listUpstreamsUnderFailure(gpa: Allocator) !void {
|
|||||||
test "listUpstreams is leak-safe under allocation failure" {
|
test "listUpstreams is leak-safe under allocation failure" {
|
||||||
try testing.checkAllAllocationFailures(testing.allocator, listUpstreamsUnderFailure, .{});
|
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, .{});
|
||||||
|
}
|
||||||
|
|||||||
+57
-27
@@ -26,6 +26,7 @@ pub const vacuum_every_passes = 7;
|
|||||||
/// expresses, so a finer schedule would prune nothing new.
|
/// expresses, so a finer schedule would prune nothing new.
|
||||||
pub const pass_interval_s = 86_400;
|
pub const pass_interval_s = 86_400;
|
||||||
|
|
||||||
|
/// A consistent copy of the counters, for `/metrics` and the health rollup.
|
||||||
pub const Stats = struct {
|
pub const Stats = struct {
|
||||||
passes: u64 = 0,
|
passes: u64 = 0,
|
||||||
rows_pruned: u64 = 0,
|
rows_pruned: u64 = 0,
|
||||||
@@ -33,12 +34,34 @@ pub const Stats = struct {
|
|||||||
vacuums: u64 = 0,
|
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 {
|
pub const Retention = struct {
|
||||||
cfg: model.Logging,
|
cfg: model.Logging,
|
||||||
stats: Stats,
|
counters: Counters,
|
||||||
|
|
||||||
pub fn init(cfg: model.Logging) Retention {
|
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.
|
/// 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`.
|
/// `database` must be a connection no other task uses; see `run`.
|
||||||
pub fn runOnce(self: *Retention, io: std.Io, database: *db.Db) void {
|
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);
|
const cutoff = std.Io.Clock.real.now(io).toSeconds() - model.retentionSeconds(self.cfg);
|
||||||
|
|
||||||
if (queries_repo.pruneOlderThan(database, cutoff)) |deleted| {
|
if (queries_repo.pruneOlderThan(database, cutoff)) |deleted| {
|
||||||
self.stats.rows_pruned += @intCast(deleted);
|
_ = add(&self.counters.rows_pruned, @intCast(deleted));
|
||||||
} else |err| {
|
} else |err| {
|
||||||
log.warn("retention prune before {d} failed: {s}", .{ cutoff, @errorName(err) });
|
log.warn("retention prune before {d} failed: {s}", .{ cutoff, @errorName(err) });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queries_repo.checkpointTruncate(database)) {
|
if (queries_repo.checkpointTruncate(database)) {
|
||||||
self.stats.checkpoints += 1;
|
_ = add(&self.counters.checkpoints, 1);
|
||||||
} else |err| {
|
} else |err| {
|
||||||
log.warn("retention checkpoint failed: {s}", .{@errorName(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)) {
|
if (queries_repo.vacuum(database)) {
|
||||||
self.stats.vacuums += 1;
|
_ = add(&self.counters.vacuums, 1);
|
||||||
} else |err| {
|
} else |err| {
|
||||||
log.warn("retention vacuum failed: {s}", .{@errorName(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.
|
/// Daily loop, first pass immediately. Phase 7 starts it.
|
||||||
///
|
///
|
||||||
/// `boot` rather than `awake`: a box that suspends overnight must still see
|
/// `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);
|
retention.runOnce(io, &database);
|
||||||
|
|
||||||
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&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, 1), retention.snapshotStats().passes);
|
||||||
try testing.expectEqual(@as(u64, 2), retention.stats.rows_pruned);
|
try testing.expectEqual(@as(u64, 2), retention.snapshotStats().rows_pruned);
|
||||||
try testing.expectEqual(@as(u64, 1), retention.stats.checkpoints);
|
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().checkpoints);
|
||||||
try testing.expectEqual(@as(u64, 0), retention.stats.vacuums);
|
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().vacuums);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "the cutoff follows retention_days" {
|
test "the cutoff follows retention_days" {
|
||||||
@@ -182,12 +212,12 @@ test "the cutoff follows retention_days" {
|
|||||||
var keeps: Retention = .init(.{ .retention_days = 7 });
|
var keeps: Retention = .init(.{ .retention_days = 7 });
|
||||||
keeps.runOnce(io, &database);
|
keeps.runOnce(io, &database);
|
||||||
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&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 });
|
var prunes: Retention = .init(.{ .retention_days = 1 });
|
||||||
prunes.runOnce(io, &database);
|
prunes.runOnce(io, &database);
|
||||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&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" {
|
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(.{});
|
var retention: Retention = .init(.{});
|
||||||
for (0..6) |_| {
|
for (0..6) |_| {
|
||||||
retention.runOnce(io, &database);
|
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);
|
retention.runOnce(io, &database);
|
||||||
|
|
||||||
try testing.expectEqual(@as(u64, 7), retention.stats.passes);
|
try testing.expectEqual(@as(u64, 7), retention.snapshotStats().passes);
|
||||||
try testing.expectEqual(@as(u64, 1), retention.stats.vacuums);
|
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().vacuums);
|
||||||
try testing.expectEqual(@as(u64, 7), retention.stats.checkpoints);
|
try testing.expectEqual(@as(u64, 7), retention.snapshotStats().checkpoints);
|
||||||
|
|
||||||
for (0..7) |_| retention.runOnce(io, &database);
|
for (0..7) |_| retention.runOnce(io, &database);
|
||||||
try testing.expectEqual(@as(u64, 14), retention.stats.passes);
|
try testing.expectEqual(@as(u64, 14), retention.snapshotStats().passes);
|
||||||
try testing.expectEqual(@as(u64, 2), retention.stats.vacuums);
|
try testing.expectEqual(@as(u64, 2), retention.snapshotStats().vacuums);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "a pass over an empty database still counts" {
|
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(.{});
|
var retention: Retention = .init(.{});
|
||||||
retention.runOnce(io, &database);
|
retention.runOnce(io, &database);
|
||||||
|
|
||||||
try testing.expectEqual(@as(u64, 1), retention.stats.passes);
|
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().passes);
|
||||||
try testing.expectEqual(@as(u64, 0), retention.stats.rows_pruned);
|
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().rows_pruned);
|
||||||
try testing.expectEqual(@as(u64, 1), retention.stats.checkpoints);
|
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().checkpoints);
|
||||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
|
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);
|
retention.runOnce(io, &database);
|
||||||
|
|
||||||
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&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, 1), retention.snapshotStats().passes);
|
||||||
try testing.expectEqual(@as(u64, 0), retention.stats.rows_pruned);
|
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().rows_pruned);
|
||||||
// The checkpoint runs whether or not the prune did.
|
// 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" {
|
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);
|
retention.runOnce(io, &database);
|
||||||
|
|
||||||
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&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.snapshotStats().passes);
|
||||||
try testing.expectEqual(@as(u64, 2), retention.stats.rows_pruned);
|
try testing.expectEqual(@as(u64, 2), retention.snapshotStats().rows_pruned);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ comptime {
|
|||||||
_ = @import("storage/migrations.zig");
|
_ = @import("storage/migrations.zig");
|
||||||
_ = @import("storage/querylog_schema.zig");
|
_ = @import("storage/querylog_schema.zig");
|
||||||
_ = @import("storage/repositories/context.zig");
|
_ = @import("storage/repositories/context.zig");
|
||||||
|
_ = @import("storage/repositories/crud.zig");
|
||||||
_ = @import("storage/repositories/groups_repo.zig");
|
_ = @import("storage/repositories/groups_repo.zig");
|
||||||
_ = @import("storage/repositories/clients_repo.zig");
|
_ = @import("storage/repositories/clients_repo.zig");
|
||||||
_ = @import("storage/repositories/upstreams_repo.zig");
|
_ = @import("storage/repositories/upstreams_repo.zig");
|
||||||
@@ -76,6 +77,37 @@ comptime {
|
|||||||
_ = @import("server/clients.zig");
|
_ = @import("server/clients.zig");
|
||||||
_ = @import("server/shutdown.zig");
|
_ = @import("server/shutdown.zig");
|
||||||
_ = @import("server/phase7_integration_test.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;
|
extern fn sqlite3_libversion() [*:0]const u8;
|
||||||
|
|||||||
@@ -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, .{});
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
@@ -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(), ""));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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'"));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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\":\"\""));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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\":\"\""));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).?,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
+439
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
//! `<name>.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 <dir>`, 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 = "<html>", .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));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,201 @@
|
|||||||
|
//! Build-time web asset indexer (milestone-8 ruling 24).
|
||||||
|
//!
|
||||||
|
//! `gen_web_assets <dist-dir> <out-dir>` reads a built web dist directory and
|
||||||
|
//! writes into `<out-dir>`:
|
||||||
|
//!
|
||||||
|
//! - `<name>.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 `<out-dir>` 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 <dist-dir> <out-dir>", .{});
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#101418"/>
|
||||||
|
<path d="M16 5l9 4v7c0 6-4 9.5-9 11-5-1.5-9-5-9-11V9z" fill="none" stroke="#6fce8f" stroke-width="2.5" stroke-linejoin="round"/>
|
||||||
|
<circle cx="16" cy="15" r="3" fill="#6fce8f"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 303 B |
@@ -0,0 +1,59 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>nxdns</title>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<style>
|
||||||
|
body { font: 16px/1.5 system-ui, sans-serif; margin: 0; background: #101418; color: #d8dee6; }
|
||||||
|
main { max-width: 40rem; margin: 4rem auto; padding: 0 1.5rem; }
|
||||||
|
h1 { font-size: 1.5rem; margin: 0 0 0.25rem; }
|
||||||
|
h1 + p { margin-top: 0; color: #8b98a8; }
|
||||||
|
dl { display: grid; grid-template-columns: max-content 1fr; gap: 0.25rem 1.5rem; background: #171d24; border: 1px solid #232c36; border-radius: 8px; padding: 1rem 1.25rem; }
|
||||||
|
dt { color: #8b98a8; }
|
||||||
|
dd { margin: 0; font-variant-numeric: tabular-nums; }
|
||||||
|
.ok { color: #6fce8f; }
|
||||||
|
.degraded { color: #e0b25b; }
|
||||||
|
.unreachable { color: #e07a6c; }
|
||||||
|
nav { margin-top: 1.5rem; }
|
||||||
|
nav a { color: #7fb3e8; margin-right: 1.25rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>nxdns</h1>
|
||||||
|
<p>DNS sinkhole — the admin interface ships in a later release.</p>
|
||||||
|
<dl>
|
||||||
|
<dt>Status</dt><dd id="status">loading…</dd>
|
||||||
|
<dt>Upstreams</dt><dd id="upstreams">–</dd>
|
||||||
|
<dt>Disk</dt><dd id="disk">–</dd>
|
||||||
|
<dt>Version</dt><dd id="version">–</dd>
|
||||||
|
</dl>
|
||||||
|
<nav>
|
||||||
|
<a href="/metrics">Metrics</a>
|
||||||
|
<a href="/api/health">Health</a>
|
||||||
|
<a href="/api/openapi.yaml">API reference</a>
|
||||||
|
</nav>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const el = (id) => document.getElementById(id);
|
||||||
|
fetch("/api/health")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((h) => {
|
||||||
|
el("status").textContent = h.status;
|
||||||
|
el("status").className = h.status === "ok" ? "ok" : "degraded";
|
||||||
|
el("upstreams").textContent = h.upstreams.available + " of " + h.upstreams.total + " available";
|
||||||
|
el("disk").textContent = h.disk.state + ", " + Math.round(h.disk.free_bytes / 1048576) + " MiB free";
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
el("status").textContent = "unreachable";
|
||||||
|
el("status").className = "unreachable";
|
||||||
|
});
|
||||||
|
fetch("/api/version")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((v) => { el("version").textContent = v.version + " (" + v.git_commit + ")"; })
|
||||||
|
.catch(() => {});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user