milestone 18: collapse duplicated infrastructure into shared listener core, crud list helper, resource shells, transport race, name and line helpers, ui modules
CI / test (push) Successful in 1m22s
CI / test-aarch64 (push) Successful in 5m6s
CI / frontend (push) Successful in 45s
CI / cross (push) Successful in 7m53s
CI / docker (push) Failing after 1h10m57s

This commit is contained in:
2026-08-07 18:20:30 +02:00
parent c50c6d285a
commit 6f67940995
82 changed files with 3167 additions and 3114 deletions
+14 -12
View File
@@ -64,37 +64,39 @@ The repo's history proves this theme's stakes: commit 35f2324 fixed a process-ki
## Theme 2: Duplicated infrastructure, drift already underway (11 findings) ## Theme 2: Duplicated infrastructure, drift already underway (11 findings)
- **[high] src/server/dot_server.zig:3 — connection-server lifecycle machinery duplicated across four listeners.** *(merged: tcp_server.zig:218, dot_server.zig:180, dot_server.zig:452 — four findings describing the same debt)* > Status (m18): all 11 findings closed by milestone 18 (specs/milestone-18.md).
- **[high — CLOSED m18] src/server/dot_server.zig:3 — connection-server lifecycle machinery duplicated across four listeners.** *(merged: tcp_server.zig:218, dot_server.zig:180, dot_server.zig:452 — four findings describing the same debt)*
tcp_server, dot_server, doh_server, and web/server each carry a private copy of the same slot pool, claim/finish/beginShutdown protocol, accept loop with error mapping, cancel-protection dance, and select-based idle race. `race`/`expire`/`readPrefix`/`readBody`/`writeReply` are byte-identical between tcp and dot; dot_server's header says outright "This file mirrors tcp_server.zig". Drift is live: the counter is `accepted` on TCP vs `connections` on DoT/DoH; `decideClaim` is written two ways for identical semantics; doh_server ships a module-level `serve()` that nothing calls (dead divergent glue); the milestone-10 review log records a TLS-context leak fixed by hand-porting a pattern between copies — the predicted failure mode has already fired once. This is the most invariant-heavy concurrency code in the repo (mutex-ordered shutdown, cancel-protection windows), copied four times with no compiler help. The milestone-10 "mirror tcp_server's shape" ruling was a parallel-session build instruction, not an architectural decision against extraction. Fix: one shared listener core (comptime-parameterized slot pool + claim/shutdown + accept loop + race) with the per-connection serve function and TLS handshake stage as variation points; at minimum, extract race/expire/readPrefix/bump/Stop into src/server/ helpers. tcp_server, dot_server, doh_server, and web/server each carry a private copy of the same slot pool, claim/finish/beginShutdown protocol, accept loop with error mapping, cancel-protection dance, and select-based idle race. `race`/`expire`/`readPrefix`/`readBody`/`writeReply` are byte-identical between tcp and dot; dot_server's header says outright "This file mirrors tcp_server.zig". Drift is live: the counter is `accepted` on TCP vs `connections` on DoT/DoH; `decideClaim` is written two ways for identical semantics; doh_server ships a module-level `serve()` that nothing calls (dead divergent glue); the milestone-10 review log records a TLS-context leak fixed by hand-porting a pattern between copies — the predicted failure mode has already fired once. This is the most invariant-heavy concurrency code in the repo (mutex-ordered shutdown, cancel-protection windows), copied four times with no compiler help. The milestone-10 "mirror tcp_server's shape" ruling was a parallel-session build instruction, not an architectural decision against extraction. Fix: one shared listener core (comptime-parameterized slot pool + claim/shutdown + accept loop + race) with the per-connection serve function and TLS handshake stage as variation points; at minimum, extract race/expire/readPrefix/bump/Stop into src/server/ helpers.
- **[medium] src/storage/repositories/groups_repo.zig:29 — list/free/errdefer scaffolding hand-rolled 18 times across seven repos.** - **[medium — CLOSED m18] src/storage/repositories/groups_repo.zig:29 — list/free/errdefer scaffolding hand-rolled 18 times across seven repos.**
Every repo re-implements prepare → ArrayList → the load-bearing errdefer ordering → per-column columnTextAlloc → append, plus a matching freeX and allocation-failure test. Decisive detail from verification: the milestone-4 spec's own reference sample declares the two errdefers in the reverse (fatal, use-after-free) order — every implementation silently corrected it, and a future repo copied from the spec reproduces the UAF unless its author also copies the test. Fix: a shared comptime helper in crud.zig (`list(RowType, sql, readRow)` plus paired free) so the memory-safety choreography exists once. Every repo re-implements prepare → ArrayList → the load-bearing errdefer ordering → per-column columnTextAlloc → append, plus a matching freeX and allocation-failure test. Decisive detail from verification: the milestone-4 spec's own reference sample declares the two errdefers in the reverse (fatal, use-after-free) order — every implementation silently corrected it, and a future repo copied from the spec reproduces the UAF unless its author also copies the test. Fix: a shared comptime helper in crud.zig (`list(RowType, sql, readRow)` plus paired free) so the memory-safety choreography exists once.
- **[medium] src/web/handlers/groups.zig:168 — CRUD shell duplicated across seven handler files; the 4-line configDb switch appears 40 times.** - **[medium — CLOSED m18] src/web/handlers/groups.zig:168 — CRUD shell duplicated across seven handler files; the 4-line configDb switch appears 40 times.**
list/get/remove handlers and the applyCreate/Update/Delete shells are identical modulo repo function and message string, and the copies have already diverged in lock scope and reload flavor (local.zig holds the lock through publish; blocklists adds pruneFiles; groups alone reads back under the lock). Fix: a comptime resource descriptor (repo fns + conflict message + reload flavor) generating the shells, keeping the genuinely different per-resource decision functions hand-written. Note the four distinct reload flavors make this less mechanical than the finding implies. list/get/remove handlers and the applyCreate/Update/Delete shells are identical modulo repo function and message string, and the copies have already diverged in lock scope and reload flavor (local.zig holds the lock through publish; blocklists adds pruneFiles; groups alone reads back under the lock). Fix: a comptime resource descriptor (repo fns + conflict message + reload flavor) generating the shells, keeping the genuinely different per-resource decision functions hand-written. Note the four distinct reload flavors make this less mechanical than the finding implies.
- **[medium] web/src/features/local/RecordsTab.tsx:25 — RecordsTab/ZonesTab are structural copy-paste, and Tailwind class constants are re-declared across 12 files.** *(merged: RecordsTab.tsx:14, the class-constant half of the same debt)* - **[medium — CLOSED m18] web/src/features/local/RecordsTab.tsx:25 — RecordsTab/ZonesTab are structural copy-paste, and Tailwind class constants are re-declared across 12 files.** *(merged: RecordsTab.tsx:14, the class-constant half of the same debt)*
The two tabs share identical FormState/openForm/onSubmit/onDelete plumbing, mutation trio, and byte-identical class constants; the focus-visible literal appears in 21 files, and drift has already shipped: PrefixesEditor and GroupsPage inputs omit the focus-visible outline entirely, silently violating the milestone-9 accessibility floor. The project's own precedent (InlineError was hoisted and deduplicated during milestone 9) says this is house style left unapplied. Fix: shared form-shell/table-shell components and a ui/ directory (or one classes.ts module). The two tabs share identical FormState/openForm/onSubmit/onDelete plumbing, mutation trio, and byte-identical class constants; the focus-visible literal appears in 21 files, and drift has already shipped: PrefixesEditor and GroupsPage inputs omit the focus-visible outline entirely, silently violating the milestone-9 accessibility floor. The project's own precedent (InlineError was hoisted and deduplicated during milestone 9) says this is house style left unapplied. Fix: shared form-shell/table-shell components and a ui/ directory (or one classes.ts module).
- **[low] src/local/forward_client.zig:260 — race-against-deadline and stream-error-unwrap scaffolding copied across three transports.** - **[low — CLOSED m18] src/local/forward_client.zig:260 — race-against-deadline and stream-error-unwrap scaffolding copied across three transports.**
The Outcome union + expire() select race is byte-identical between pool.zig and forward_client.zig (`fn expire(` appears 14 times tree-wide); the cancel-protected close helpers and mapPhase/sendFailure/receiveFailure repeat between dot_client and forward_client. The predicted drift is already real: doh_client has none of the unwrap (see Theme 3). Fix: shared helpers in transport.zig, which already owns the framing helpers and error taxonomy. The Outcome union + expire() select race is byte-identical between pool.zig and forward_client.zig (`fn expire(` appears 14 times tree-wide); the cancel-protected close helpers and mapPhase/sendFailure/receiveFailure repeat between dot_client and forward_client. The predicted drift is already real: doh_client has none of the unwrap (see Theme 3). Fix: shared helpers in transport.zig, which already owns the framing helpers and error taxonomy.
- **[low] src/local/records.zig:197 — normalizeName duplicated verbatim in forward_zones.zig, with a third partial copy in dns_cache.buildKey.** - **[low — CLOSED m18] src/local/records.zig:197 — normalizeName duplicated verbatim in forward_zones.zig, with a third partial copy in dns_cache.buildKey.**
Divergence risk is not hypothetical: filter/rules.zig and filter/compiler.zig already carry near-variants with differing byte-rejection policies. Fix: move it to dns/name.zig next to fromText (both callers already import it). Divergence risk is not hypothetical: filter/rules.zig and filter/compiler.zig already carry near-variants with differing byte-rejection policies. Fix: move it to dns/name.zig next to fromText (both callers already import it).
- **[low] src/filter/compiler.zig:64 — the subtle takeDelimiter/StreamTooLong/discard streaming loop exists twice (compile and manager.collectSample).** - **[low — CLOSED m18] src/filter/compiler.zig:64 — the subtle takeDelimiter/StreamTooLong/discard streaming loop exists twice (compile and manager.collectSample).**
Both copies are correct and tested, and the cited behavioral differences are intentional per call site; the risk is a std.Io.Reader semantics change or a third copy-paste. Fix: a shared bounded-line-iterator helper. (The 35f2324 linkage in the original finding was inaccurate — that bug was reader-buffer aliasing, a different class.) Both copies are correct and tested, and the cited behavioral differences are intentional per call site; the risk is a std.Io.Reader semantics change or a third copy-paste. Fix: a shared bounded-line-iterator helper. (The 35f2324 linkage in the original finding was inaccurate — that bug was reader-buffer aliasing, a different class.)
- **[low] src/cli.zig:906 — upstream client construction duplicated between probeUpstreams and app.Upstreams.build.** - **[low — CLOSED m18] src/cli.zig:906 — upstream client construction duplicated between probeUpstreams and app.Upstreams.build.**
Concrete drift hazard: cli.zig hardcodes the DoH buffer sizes as literals that app.zig defines as named constants — changing the constants leaves `check` probing different buffers than `run` uses, undermining the probe's stated purpose. Fix: a shared build-one-client-per-entry helper, or at minimum shared size constants. Concrete drift hazard: cli.zig hardcodes the DoH buffer sizes as literals that app.zig defines as named constants — changing the constants leaves `check` probing different buffers than `run` uses, undermining the probe's stated purpose. Fix: a shared build-one-client-per-entry helper, or at minimum shared size constants.
- **[low] src/cli.zig:722 — ZON parse failure rendered through two channels in `check` vs `import`.** - **[low — CLOSED m18] src/cli.zig:722 — ZON parse failure rendered through two channels in `check` vs `import`.**
check prints the multi-line zon_diag inline (embedding newlines mid-FAIL-line, contradicting the one-line-per-problem promise in docs/reference/configuration.md:332), import routes through reportParseFailure/Diagnostics. Fix: expose reportParseFailure (it is currently private) and route check through it. check prints the multi-line zon_diag inline (embedding newlines mid-FAIL-line, contradicting the one-line-per-problem promise in docs/reference/configuration.md:332), import routes through reportParseFailure/Diagnostics. Fix: expose reportParseFailure (it is currently private) and route check through it.
- **[low] build.zig:155 — test-suite module wiring duplicated block-for-block for host and aarch64, plus a re-spelled target triple.** - **[low — CLOSED m18] build.zig:155 — test-suite module wiring duplicated block-for-block for host and aarch64, plus a re-spelled target triple.**
Divergence is caught loudly (compile/link errors, blocking CI qemu job), just late. Fix: an addTestSuite helper mirroring addExecutable, triple from cross_targets. Divergence is caught loudly (compile/link errors, blocking CI qemu job), just late. Fix: an addTestSuite helper mirroring addExecutable, triple from cross_targets.
- **[low] tests/fuzz/blocklist_fuzz.zig:160 — Smith corpus encoders (u32-LE length-prefix convention) copy-pasted between the two fuzz files.** - **[low — CLOSED m18] tests/fuzz/blocklist_fuzz.zig:160 — Smith corpus encoders (u32-LE length-prefix convention) copy-pasted between the two fuzz files.**
A Smith stream-format change caught in one file and missed in the other leaves that corpus silently decoding to garbage while targets stay green. Fix: a shared dependency-free tests/fuzz/smith_encode.zig — no build.zig change needed since dns_fuzz.zig already imports corpus.zig by relative path. A Smith stream-format change caught in one file and missed in the other leaves that corpus silently decoding to garbage while targets stay green. Fix: a shared dependency-free tests/fuzz/smith_encode.zig — no build.zig change needed since dns_fuzz.zig already imports corpus.zig by relative path.
## Theme 3: Silent failures and swallowed errors (11 findings) ## Theme 3: Silent failures and swallowed errors (11 findings)
@@ -111,7 +113,7 @@ The repo's history proves this theme's stakes: commit 35f2324 fixed a process-ki
- **[medium — CLOSED m16] web/src/features/queries/QueryLogPage.tsx:88 — load-more accumulation silently develops a mid-table row gap when the base page refetches.** - **[medium — CLOSED m16] web/src/features/queries/QueryLogPage.tsx:88 — load-more accumulation silently develops a mid-table row gap when the base page refetches.**
The refocus-after-30s refetch shifts the newest-100 boundary up while `extra` starts strictly below the old cursor; the missing rows are in neither, and the stale cursorOverride means load-more never heals it. On a live DNS server the trigger is routine. Fix: useInfiniteQuery (or disable background refetch while extra is non-empty), or detect the discontinuity and reset the accumulation. The refocus-after-30s refetch shifts the newest-100 boundary up while `extra` starts strictly below the old cursor; the missing rows are in neither, and the stale cursorOverride means load-more never heals it. On a live DNS server the trigger is routine. Fix: useInfiniteQuery (or disable background refetch while extra is non-empty), or detect the discontinuity and reset the accumulation.
- **[low] src/upstream/doh_client.zig:157 — DoH mapError never unwraps the stashed cause behind ReadFailed/WriteFailed.** - **[low — CLOSED m18] src/upstream/doh_client.zig:157 — DoH mapError never unwraps the stashed cause behind ReadFailed/WriteFailed.**
Verification narrowed the blast radius: the pool's select harness means shutdown cancellation is handled correctly despite this, so the reachable impact is rare mid-exchange local-resource errors (e.g. ENOBUFS) recorded as peer faults against a healthy upstream — still a stated spec-invariant violation, fixed once for DoT and left in DoH. Fix: the same unwrap the other two transports carry. Verification narrowed the blast radius: the pool's select harness means shutdown cancellation is handled correctly despite this, so the reachable impact is rare mid-exchange local-resource errors (e.g. ENOBUFS) recorded as peer faults against a healthy upstream — still a stated spec-invariant violation, fixed once for DoT and left in DoH. Fix: the same unwrap the other two transports carry.
- **[low — CLOSED m16] src/filter/manager.zig:1237 — commitStatus silently drops the outcome of a source with no status entry.** - **[low — CLOSED m16] src/filter/manager.zig:1237 — commitStatus silently drops the outcome of a source with no status entry.**
+114 -104
View File
@@ -12,6 +12,16 @@ const cross_targets = [_][]const u8{
"aarch64-linux-musl", "aarch64-linux-musl",
}; };
/// The deploy target, read out of `cross_targets` so `test-aarch64` and `cross`
/// cannot describe different machines. Reordering the array is caught here
/// rather than by a qemu job that quietly ran the wrong architecture.
const aarch64_triple = cross_targets[1];
comptime {
if (!std.mem.startsWith(u8, aarch64_triple, "aarch64-")) {
@compileError("cross_targets[1] must be the aarch64 triple, found " ++ aarch64_triple);
}
}
pub fn build(b: *std.Build) void { pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{}); const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{}); const optimize = b.standardOptimizeOption(.{});
@@ -109,31 +119,7 @@ pub fn build(b: *std.Build) void {
// //
// No upstream issue matched a search of ziglang/zig for this behaviour; // No upstream issue matched a search of ziglang/zig for this behaviour;
// the reference is the 0.16.0 source lines above. See AGENTS.md. // the reference is the 0.16.0 source lines above. See AGENTS.md.
const tests = b.addTest(.{ const tests = addTestSuite(b, target, optimize, options, web_assets);
.root_module = b.createModule(.{
.root_source_file = b.path("src/tests.zig"),
.target = target,
.optimize = optimize,
.link_libc = true,
}),
});
tests.root_module.addOptions("build_options", options);
tests.root_module.linkLibrary(sqliteLibrary(b, target, optimize));
tests.root_module.linkLibrary(mbedtlsLibrary(b, target, optimize));
tests.root_module.addCSourceFile(.{ .file = b.path("src/platform/mbedtls_shim.c") });
addMbedtlsThreadingMacros(tests.root_module);
tests.root_module.addAnonymousImport("test_fixtures", .{
.root_source_file = b.path("tests/fixtures/fixtures.zig"),
});
tests.root_module.addAnonymousImport("docs_files", .{
.root_source_file = b.path("docs/docs.zig"),
});
// An anonymous-import root must be Zig, so the committed TypeScript golden
// is reached through a one-decl wrapper beside it.
tests.root_module.addAnonymousImport("contract_samples", .{
.root_source_file = b.path("web/src/lib/contract_samples.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);
@@ -142,61 +128,28 @@ pub fn build(b: *std.Build) void {
// the in-file tests). `-Dfuzz` opts into the LLVM backend, which `--fuzz` // the in-file tests). `-Dfuzz` opts into the LLVM backend, which `--fuzz`
// needs for sanitizer coverage; stock 0.16.0 also requires a patched // needs for sanitizer coverage; stock 0.16.0 also requires a patched
// test_runner.zig for fuzz mode — see specs/milestone-2.md. // test_runner.zig for fuzz mode — see specs/milestone-2.md.
const dns_mod = b.createModule(.{ addFuzzSuite(b, target, optimize, fuzz, test_step, .{
.root_source_file = b.path("src/dns/dns.zig"),
.target = target,
.optimize = optimize,
});
const fuzz_mod = b.createModule(.{
.root_source_file = b.path("tests/fuzz/dns_fuzz.zig"),
.target = target,
.optimize = optimize,
});
fuzz_mod.addImport("dns", dns_mod);
const fuzz_tests = b.addTest(.{
.name = "fuzz", .name = "fuzz",
.use_llvm = if (fuzz) true else null, .root = "tests/fuzz/dns_fuzz.zig",
.root_module = fuzz_mod, .import_name = "dns",
.import_module = sourceModule(b, target, optimize, "src/dns/dns.zig"),
}); });
test_step.dependOn(&b.addRunArtifact(fuzz_tests).step);
const parsers_mod = b.createModule(.{ addFuzzSuite(b, target, optimize, fuzz, test_step, .{
.root_source_file = b.path("src/filter/parsers.zig"),
.target = target,
.optimize = optimize,
});
const blocklist_fuzz_mod = b.createModule(.{
.root_source_file = b.path("tests/fuzz/blocklist_fuzz.zig"),
.target = target,
.optimize = optimize,
});
blocklist_fuzz_mod.addImport("parsers", parsers_mod);
const blocklist_fuzz_tests = b.addTest(.{
.name = "blocklist-fuzz", .name = "blocklist-fuzz",
.use_llvm = if (fuzz) true else null, .root = "tests/fuzz/blocklist_fuzz.zig",
.root_module = blocklist_fuzz_mod, .import_name = "parsers",
.import_module = sourceModule(b, target, optimize, "src/filter/parsers.zig"),
}); });
test_step.dependOn(&b.addRunArtifact(blocklist_fuzz_tests).step);
// `src/web/http_util.zig` imports only std, so its fuzz module roots // `src/web/http_util.zig` imports only std, so its fuzz module roots
// directly at the file — no aggregator needed (milestone-15 ruling 6c). // directly at the file — no aggregator needed (milestone-15 ruling 6c).
const http_util_mod = b.createModule(.{ addFuzzSuite(b, target, optimize, fuzz, test_step, .{
.root_source_file = b.path("src/web/http_util.zig"),
.target = target,
.optimize = optimize,
});
const http_util_fuzz_mod = b.createModule(.{
.root_source_file = b.path("tests/fuzz/http_util_fuzz.zig"),
.target = target,
.optimize = optimize,
});
http_util_fuzz_mod.addImport("http_util", http_util_mod);
const http_util_fuzz_tests = b.addTest(.{
.name = "http-util-fuzz", .name = "http-util-fuzz",
.use_llvm = if (fuzz) true else null, .root = "tests/fuzz/http_util_fuzz.zig",
.root_module = http_util_fuzz_mod, .import_name = "http_util",
.import_module = sourceModule(b, target, optimize, "src/web/http_util.zig"),
}); });
test_step.dependOn(&b.addRunArtifact(http_util_fuzz_tests).step);
// The bench harness (milestone-12 ruling 1). The measured roots // The bench harness (milestone-12 ruling 1). The measured roots
// (matcher.zig, dns_cache.zig, compiler.zig) share files in their relative // (matcher.zig, dns_cache.zig, compiler.zig) share files in their relative
@@ -226,18 +179,12 @@ pub fn build(b: *std.Build) void {
.target = target, .target = target,
.optimize = optimize, .optimize = optimize,
}); });
const compiler_fuzz_mod = b.createModule(.{ addFuzzSuite(b, target, optimize, fuzz, test_step, .{
.root_source_file = b.path("tests/fuzz/compiler_fuzz.zig"),
.target = target,
.optimize = optimize,
});
compiler_fuzz_mod.addImport("core", bench_core_mod);
const compiler_fuzz_tests = b.addTest(.{
.name = "compiler-fuzz", .name = "compiler-fuzz",
.use_llvm = if (fuzz) true else null, .root = "tests/fuzz/compiler_fuzz.zig",
.root_module = compiler_fuzz_mod, .import_name = "core",
.import_module = bench_core_mod,
}); });
test_step.dependOn(&b.addRunArtifact(compiler_fuzz_tests).step);
const bench_mod = b.createModule(.{ const bench_mod = b.createModule(.{
.root_source_file = b.path("tools/bench.zig"), .root_source_file = b.path("tools/bench.zig"),
@@ -256,32 +203,10 @@ pub fn build(b: *std.Build) void {
// -Dintegration stays out (ruling 7): qemu-user's slowdown makes the // -Dintegration stays out (ruling 7): qemu-user's slowdown makes the
// wall-clock-budgeted loopback TLS tests a flake source. // wall-clock-budgeted loopback TLS tests a flake source.
const aarch64_target = b.resolveTargetQuery( const aarch64_target = b.resolveTargetQuery(
std.Target.Query.parse(.{ .arch_os_abi = "aarch64-linux-musl" }) catch unreachable, std.Target.Query.parse(.{ .arch_os_abi = aarch64_triple }) catch unreachable,
); );
const aarch64_tests = b.addTest(.{ const aarch64_tests = addTestSuite(b, aarch64_target, optimize, options, web_assets);
.root_module = b.createModule(.{
.root_source_file = b.path("src/tests.zig"),
.target = aarch64_target,
.optimize = optimize,
.link_libc = true,
}),
});
aarch64_tests.linkage = .static; aarch64_tests.linkage = .static;
aarch64_tests.root_module.addOptions("build_options", options);
aarch64_tests.root_module.linkLibrary(sqliteLibrary(b, aarch64_target, optimize));
aarch64_tests.root_module.linkLibrary(mbedtlsLibrary(b, aarch64_target, optimize));
aarch64_tests.root_module.addCSourceFile(.{ .file = b.path("src/platform/mbedtls_shim.c") });
addMbedtlsThreadingMacros(aarch64_tests.root_module);
aarch64_tests.root_module.addAnonymousImport("test_fixtures", .{
.root_source_file = b.path("tests/fixtures/fixtures.zig"),
});
aarch64_tests.root_module.addAnonymousImport("docs_files", .{
.root_source_file = b.path("docs/docs.zig"),
});
aarch64_tests.root_module.addAnonymousImport("contract_samples", .{
.root_source_file = b.path("web/src/lib/contract_samples.zig"),
});
aarch64_tests.root_module.addAnonymousImport("web_assets", .{ .root_source_file = web_assets });
const aarch64_run = b.addRunArtifact(aarch64_tests); const aarch64_run = b.addRunArtifact(aarch64_tests);
aarch64_run.skip_foreign_checks = true; aarch64_run.skip_foreign_checks = true;
b.step("test-aarch64", "Run the test suite for aarch64-linux-musl (use -fqemu)") b.step("test-aarch64", "Run the test suite for aarch64-linux-musl (use -fqemu)")
@@ -348,6 +273,91 @@ fn checkTestImports(b: *std.Build) void {
} }
} }
/// The `src/tests.zig` suite, wired for one target. The host build and the
/// aarch64 build take the same artifact; their only differences (`linkage` and
/// `skip_foreign_checks`) stay at the call sites.
fn addTestSuite(
b: *std.Build,
target: std.Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
options: *std.Build.Step.Options,
web_assets: std.Build.LazyPath,
) *std.Build.Step.Compile {
const tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/tests.zig"),
.target = target,
.optimize = optimize,
.link_libc = true,
}),
});
tests.root_module.addOptions("build_options", options);
tests.root_module.linkLibrary(sqliteLibrary(b, target, optimize));
tests.root_module.linkLibrary(mbedtlsLibrary(b, target, optimize));
tests.root_module.addCSourceFile(.{ .file = b.path("src/platform/mbedtls_shim.c") });
addMbedtlsThreadingMacros(tests.root_module);
tests.root_module.addAnonymousImport("test_fixtures", .{
.root_source_file = b.path("tests/fixtures/fixtures.zig"),
});
tests.root_module.addAnonymousImport("docs_files", .{
.root_source_file = b.path("docs/docs.zig"),
});
// An anonymous-import root must be Zig, so the committed TypeScript golden
// is reached through a one-decl wrapper beside it.
tests.root_module.addAnonymousImport("contract_samples", .{
.root_source_file = b.path("web/src/lib/contract_samples.zig"),
});
tests.root_module.addAnonymousImport("web_assets", .{ .root_source_file = web_assets });
return tests;
}
/// One fuzz test artifact: a module rooted at a `tests/fuzz/` file plus the one
/// named import through which that target reaches the code under test. The fuzz
/// suites cannot share `addTestSuite` — they take no `build_options`, no
/// anonymous imports and no C libraries, and they carry `use_llvm`, which the
/// main suite must not set.
fn addFuzzSuite(
b: *std.Build,
target: std.Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
fuzz: bool,
test_step: *std.Build.Step,
spec: struct {
name: []const u8,
root: []const u8,
import_name: []const u8,
import_module: *std.Build.Module,
},
) void {
const mod = b.createModule(.{
.root_source_file = b.path(spec.root),
.target = target,
.optimize = optimize,
});
mod.addImport(spec.import_name, spec.import_module);
const tests = b.addTest(.{
.name = spec.name,
.use_llvm = if (fuzz) true else null,
.root_module = mod,
});
test_step.dependOn(&b.addRunArtifact(tests).step);
}
/// A plain module over one source file, with no imports of its own: what a fuzz
/// target reaches its code under test through.
fn sourceModule(
b: *std.Build,
target: std.Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
path: []const u8,
) *std.Build.Module {
return b.createModule(.{
.root_source_file = b.path(path),
.target = target,
.optimize = optimize,
});
}
fn addExecutable( fn addExecutable(
b: *std.Build, b: *std.Build,
target: std.Build.ResolvedTarget, target: std.Build.ResolvedTarget,
+4 -1
View File
@@ -286,7 +286,10 @@ store-deinit and the refs==0 assert holds. `dot_alpn` (["dot"]) lives in app.zig
Deviation: `doh_server.serve` module entry is NOT used — it constructs the DohServer in Deviation: `doh_server.serve` module entry is NOT used — it constructs the DohServer in
its own task frame, so WebState could never get the pointer the `nxdns_doh_server_*` its own task frame, so WebState could never get the pointer the `nxdns_doh_server_*`
family needs; both listeners bind in app.zig's frame instead (tcp_server style). family needs; both listeners bind in app.zig's frame instead (tcp_server style).
`doh_server.serve` remains as unused pub API. metrics.zig: `DohListenerSample` `doh_server.serve` remains as unused pub API. **Superseded by milestone-18 ruling 1: the
unused `doh_server.serve` is deleted. Nothing ever called it, and a dead second
composition path over the listener core is exactly the divergence that milestone
collapsed. app.zig's in-frame bind is now the only way DoH comes up.** metrics.zig: `DohListenerSample`
(ruling-10 four + `bad_requests`); DoT renders `dot_server.StatsSnapshot` directly; (ruling-10 four + `bad_requests`); DoT renders `dot_server.StatsSnapshot` directly;
accept-side counters stay off the exposition; unwired listeners omit the families. accept-side counters stay off the exposition; unwired listeners omit the families.
WebState gains `doh_listener`/`dot_listener` optional pointers. build.zig (out of WebState gains `doh_listener`/`dot_listener` optional pointers. build.zig (out of
+130
View File
@@ -406,6 +406,136 @@ dead `doh_server.serve`, the two `normalizeName` copies and their
shared InlineError. `npm run test`, `typecheck`, `lint` green. shared InlineError. `npm run test`, `typecheck`, `lint` green.
- [ ] Full suite green: `zig build test -Dintegration`. - [ ] Full suite green: `zig build test -Dintegration`.
## Recorded (implementation)
Accepted deviations and findings from the built milestone. Each was
reviewed and accepted at integration; the rulings above stand except as
recorded here.
### Ruling 1 (S1)
- `Cfg` supplies more than the four listed members: `Owner`,
`ConnPayload`, `serveConn`, `read_buffer_len`, `write_buffer_len`,
plus `log` (the owner's `std.log` scope) and `name`, because the
accept loop and shutdown log and the text had to stay byte-identical.
Two optional decls: `refuse` (absent means close the stream; the web
listener supplies its 503) and `initPayload`/`deinitPayload`, which
exist only for the web arena's create-in-listen / destroy-in-deinit
lifecycle.
- The read/write staging buffers live in the core's `Conn`, not in
`ConnPayload` — all four listeners have exactly one of each and
differ only in size. The web listener's `recv_buf`/`send_buf` are now
`read_buf`/`write_buf`.
- Stats layout: core counters live at `server.core.stats.*`
(`listener.CoreStats`); listener-specific counters stay on the owner
at `server.stats.*`. `tcp_server.Stats` is an alias of `CoreStats`.
Exported snapshots stay flat, so /metrics output is unchanged except
the tcp rename. `idle_timeouts` sits in `CoreStats` per the ruling,
which gives the web listener a counter it never bumps; it exports no
family, so nothing is visible.
- The docs-reference update for the rename has no target:
`nxdns_tcp_server_accepted_total` appears in no file under docs/ or
web/. Only the metrics name test changed.
- 18 duplicated unit tests were deleted (6 tcp, 6 dot, 3 doh, 3 web)
and replaced by 6 shared claim-rule tests in `listener.zig`.
`web/server_integration_test.zig`'s `withServer` now returns a local
plain-`u64` `Counters` struct instead of `server.Stats`.
- File-ownership breach: S1 edited two files owned by other sessions,
both mechanical fallout of the sanctioned `deinit` shape change —
three call sites in `src/app.zig` (S5) and one line in
`src/web/web_integration_test.zig` (S3). Both owners reviewed and
kept the edits. S1 also added the required `src/tests.zig` import
line for the new file.
### Ruling 2 (S2)
- `listRowsBound(comptime Row, database, gpa, comptime sql, args:
anytype, comptime readRow)` exists beside `listRows` for the bound
queries; `freeRow` exists beside `freeRows`. Unknown owning field
shapes are a `@compileError` as ruled. The milestone-4 sample
correction was made by S2, not the orchestrator.
### Ruling 3 (S3)
- `plural` is a separate descriptor member: the "listing X" log context
differs from the JSON envelope key for three resources
(`local_records`, `client_prefixes`, `forward_zones`), so deriving one
from the other would change three log strings.
- `view` is an optional descriptor member: rules and local records map
rows through `RuleView`/`RecordView`; without it neither could adopt
without changing its response body. When absent, the row serializes
as-is with no copy.
- `remove` has two comptime-detected arities: three handlers' decision
functions read rows and take an `Allocator` before the id, three do
not. The generator validates the full signature of whichever shape it
finds.
### Ruling 4 (S4)
- The ruling's line numbers were stale after m17 (pool.zig's race sites
were at 184-206 and 306-335).
- `closeBlocked` branches at comptime on the close method's parameter
count: `tls_client.TlsStream.close()` takes no `Io` (it owns the one
it was built with), while the other three closes take `io`.
- "The stashed-cause accessor" is three accessors, one per collapse
point: the send phase reads `req.connection.?.stream_writer.err`,
`receiveHead` reads `Connection.getReadError()`, and the body read
consults `Response.bodyErr()` first (HTTP framing faults) then the
connection.
- `Connection.getReadError` can panic (it reads `stream_reader.err.?`).
The unwrap guards the plain-connection no-cause case; the TLS case
relies on std's documented contract that a cause exists after
`error.ReadFailed`.
- `raceWithin` requires the raced function's return type to be exactly
`ExchangeError!T` at comptime. Stricter than the ruling asked; it is
what keeps every failure path inside the race group. Consequence:
`manager.zig`'s `fetchWithin` (optional per the ruling) cannot adopt
it as written — its raced function has a different error set.
- The acceptance line "`fn expire(` production copies are gone" is
scoped to ruling 4's transport files. `filter/manager.zig`,
`storage/logger.zig` and `server/listener.zig` keep their own — none
is transport code.
### Rulings 5, 6, 7 (S5, S4)
- `nextBoundedLine` returns `.long_line` from the
EndOfStream-during-discard arm, and the next call returns `null`. The
two originals disagreed there (`compile` counted then broke;
`collectSample` returned without counting); this shape preserves both
behaviors — `compile` counts exactly as before, `collectSample`
ignores the event and sees `null`. Safe because
`discardDelimiterInclusive` drains the stream before reporting
`EndOfStream`.
- The length check runs on the raw line for both callers, as the ruling
placed it. One input changes classification: a line of exactly
`max_line_len + 1` bytes ending in `\r` was compiled before and now
counts as `long_lines`. Accepted as the intended reading.
- `check`'s per-message `FAIL` lines render import's path constant
(`FAIL config: <line:col: message>`), not the file name. `checkImpl`
prints the file path immediately above and `check` examines one
source per run, and this keeps `check` and `import` rendering the
same failure identically — the acceptance criterion.
### Rulings 8, 9 (S6)
- The fuzz-suite wiring did not fold into `addTestSuite` (the ruling's
own fallback): it lives in a separate `addFuzzSuite` helper, with a
shared `sourceModule` helper. The aarch64 target resolves from
`cross_targets[1]` behind a comptime prefix guard.
- `pairInput` moved into `smith_encode.zig` rather than staying in
blocklist_fuzz: m15's fuzz files had made it a two-copy duplicate,
which is the condition ruling 9 exists to remove.
### Ruling 10 (S7)
- `ui/classes.ts` exports 17 constants, not the 8 listed — the extra
ones are the focus-ring fragment and compositions the 15 adopting
files needed to drop their literals without re-spelling anything.
- Seven ring-less focusable controls were fixed by adoption, not the
two the ruling named. The acceptance grep ("every input/button/select
carries the focus-visible fragment") was taken as the invariant over
the anti-requirement's count of two, which undercounted.
## Anti-requirements ## Anti-requirements
- No behavioral changes: this milestone moves code. The only sanctioned - No behavioral changes: this milestone moves code. The only sanctioned
+11 -2
View File
@@ -1258,18 +1258,27 @@ predicate in S5 needs the true count.
SQLite and is invalidated by the next `step`; a repository that returns a borrowed slice is a SQLite and is invalidated by the next `step`; a repository that returns a borrowed slice is a
use-after-free waiting for the second row. use-after-free waiting for the second row.
- Every `list` builds into a `std.ArrayList(T)` with an `errdefer` that frees **both** every element - Every `list` builds into a `std.ArrayList(T)` with an `errdefer` that frees **both** every element
already appended and every string of the partially-built element: already appended and every string of the partially-built element. The two list-level `errdefer`s
run in reverse declaration order, so the free pass is declared **after** `out.deinit` to run
**before** it — the other order reads `out.items` once the backing array is already released:
```zig ```zig
var out: std.ArrayList(model.Group) = .empty; var out: std.ArrayList(model.Group) = .empty;
errdefer freeGroups(gpa, out.items);
errdefer out.deinit(gpa); errdefer out.deinit(gpa);
errdefer freeGroups(gpa, out.items);
while (try stmt.step()) { while (try stmt.step()) {
const name = try stmt.columnTextAlloc(gpa, 0); const name = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(name); errdefer gpa.free(name);
try out.append(gpa, .{ .name = name, .safe_search = stmt.columnBool(1) }); try out.append(gpa, .{ .name = name, .safe_search = stmt.columnBool(1) });
} }
``` ```
> **Correction (milestone 18, ruling 2).** This sample originally declared the two `errdefer`s in
> the opposite order, which frees the backing array before the pass that walks it — a
> use-after-free. Every repository written from it silently corrected the order; the sample above
> is the corrected one. Milestone 18 moved the whole choreography into
> `src/storage/repositories/crud.zig` as `listRows`/`freeRows`, so a new repository delegates to
> that helper instead of copying this shape.
- `freeX` frees every heap string in every element and is idempotent against an empty slice. - `freeX` frees every heap string in every element and is idempotent against an empty slice.
- The callers of `list` may pass an arena; `freeX` must still be correct against a general-purpose - The callers of `list` may pass an arena; `freeX` must still be correct against a general-purpose
allocator, because the tests use `std.testing.allocator`. allocator, because the tests use `std.testing.allocator`.
+7 -7
View File
@@ -84,10 +84,10 @@ const maintenance_interval_s = 60;
/// takes longer than this is not going to finish at all. /// takes longer than this is not going to finish at all.
const download_budget_s = 300; const download_budget_s = 300;
/// Per DoH upstream. `min_request_buf` is 512; the extra room costs nothing and /// Per DoH upstream. The sizes live in `doh_client.zig` so that `nxdns check`
/// keeps a maximum-length name with a large OPT record comfortable. /// probes the buffers `nxdns run` serves with.
const doh_request_buf_len = 1024; const doh_request_buf_len = doh_client.default_request_buf_len;
const doh_transfer_buf_len = 4096; const doh_transfer_buf_len = doh_client.default_transfer_buf_len;
pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 { pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 {
const code = serve(runner, args) catch |err| code: { const code = serve(runner, args) catch |err| code: {
@@ -451,7 +451,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// A failed bind warns and stays off (ruling 1, the web precedent): TLS DNS // A failed bind warns and stays off (ruling 1, the web precedent): TLS DNS
// failing to come up must not stop the plain-DNS side this box exists for. // failing to come up must not stop the plain-DNS side this box exists for.
var doh: ?doh_server.DohServer = null; var doh: ?doh_server.DohServer = null;
defer if (doh) |*server| server.deinit(gpa, io); defer if (doh) |*server| server.deinit(io);
if (doh_certs) |*store| doh = bindDoh(gpa, io, cfg.doh_server, &h, store); if (doh_certs) |*store| doh = bindDoh(gpa, io, cfg.doh_server, &h, store);
var dot: ?dot_server.DotServer = null; var dot: ?dot_server.DotServer = null;
@@ -526,7 +526,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
if (!ipv6Unavailable(err)) return reportBind(r, "tcp", v6_bind, err); if (!ipv6Unavailable(err)) return reportBind(r, "tcp", v6_bind, err);
break :bound null; break :bound null;
}; };
defer if (tcp6) |*s| s.deinit(gpa, io); defer if (tcp6) |*s| s.deinit(io);
var tcp4: ?tcp_server.TcpServer = tcp_server.TcpServer.listen(gpa, io, v4_bind, &h, .{}) catch |err| bound: { var tcp4: ?tcp_server.TcpServer = tcp_server.TcpServer.listen(gpa, io, v4_bind, &h, .{}) catch |err| bound: {
if (err != error.AddressInUse or !(tcp6 != null and isWildcard(v6_bind))) { if (err != error.AddressInUse or !(tcp6 != null and isWildcard(v6_bind))) {
@@ -535,7 +535,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
log.info("the IPv6 TCP listener is dual-stack and already serves IPv4", .{}); log.info("the IPv6 TCP listener is dual-stack and already serves IPv4", .{});
break :bound null; break :bound null;
}; };
defer if (tcp4) |*s| s.deinit(gpa, io); defer if (tcp4) |*s| s.deinit(io);
// Ruling 13: `/metrics` sums each transport's listeners into one family, so // Ruling 13: `/metrics` sums each transport's listeners into one family, so
// the web state carries pointers to whichever of the four came up. The // the web state carries pointers to whichever of the four came up. The
+4
View File
@@ -45,6 +45,10 @@ pub const max_key_len = types.max_name_len + 1 + 2 + 2 + 1 + 1 + max_ecs_len;
/// ///
/// The length bounds are assertions, not errors: both values reach here from /// The length bounds are assertions, not errors: both values reach here from
/// the packet parser, which has already rejected anything longer. /// the packet parser, which has already rejected anything longer.
///
/// The lowercase-and-strip-the-dot below is deliberately not
/// `dns.name.normalizeText`: that one validates, and this input is already
/// asserted valid, so a key build must never fail.
pub fn buildKey( pub fn buildKey(
buf: *[max_key_len]u8, buf: *[max_key_len]u8,
qname: []const u8, qname: []const u8,
+49 -4
View File
@@ -757,9 +757,15 @@ fn checkFile(r: Runner, arena: Allocator, path: []const u8, probe: bool) !u8 {
const cfg = std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) { const cfg = std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory, error.OutOfMemory => return error.OutOfMemory,
// The rendering carries the line and column, which is the whole value of // The rendering carries the line and column, which is the whole value of
// running `check` against a file the operator just edited. // running `check` against a file the operator just edited. It is
// multi-line, and `check` promises one line per problem, so it goes
// through the same `Diagnostics` channel `nxdns import` uses rather than
// into one `FAIL` record with newlines inside it.
error.ParseZon => { error.ParseZon => {
try r.out.print("FAIL {s}: {f}\n", .{ path, &zon_diag }); var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit();
try import.reportParseFailure(&diags, &zon_diag);
try diags.writeAll(r.out);
return exit_check; return exit_check;
}, },
}; };
@@ -917,8 +923,10 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
const tls_buffers = try r.gpa.alloc(u8, 4 * chunk); const tls_buffers = try r.gpa.alloc(u8, 4 * chunk);
defer r.gpa.free(tls_buffers); defer r.gpa.free(tls_buffers);
var request_buf: [1024]u8 = undefined; // The same sizes `nxdns run` serves with, so the probe reports on the
var transfer_buf: [4096]u8 = undefined; // buffers the server will actually use.
var request_buf: [doh_client.default_request_buf_len]u8 = undefined;
var transfer_buf: [doh_client.default_transfer_buf_len]u8 = undefined;
const response_buf = try r.gpa.alloc(u8, transport.max_message_len); const response_buf = try r.gpa.alloc(u8, transport.max_message_len);
defer r.gpa.free(response_buf); defer r.gpa.free(response_buf);
@@ -1461,6 +1469,43 @@ test "check --config naming a missing file is a reported failure at exit 2" {
try testing.expectEqualStrings("", captured.err.written()); try testing.expectEqualStrings("", captured.err.written());
} }
test "check renders a multi-line ZON failure as one FAIL line per message" {
// The rendering used to go inline into a single `FAIL` record, which put
// newlines mid-line and broke the one-line-per-problem promise the rest of
// `check` keeps.
var env: CheckEnv = undefined;
try env.init();
defer env.deinit();
var captured: Captured = .init(testing.allocator);
defer captured.deinit();
const r = captured.runner();
// An unexpected field renders as an "error:" line and a "note:" line.
try env.tmp.dir.writeFile(r.io, .{ .sub_path = "config.zon", .data = ".{ .grops = .{} }\n" });
var path_buf: [160]u8 = undefined;
const config_path = try env.path(&path_buf, "config.zon");
const code = runCheck(r, .{
.paths = .{ .config = config_path },
.config_explicit = true,
}, false);
try testing.expectEqual(exit_check, code);
const text = captured.out.written();
var lines = std.mem.splitScalar(u8, std.mem.trimEnd(u8, text, "\n"), '\n');
// The first line names what was checked; every line after it is a problem.
try testing.expect(std.mem.startsWith(u8, lines.next().?, "checking configuration file "));
var failures: usize = 0;
while (lines.next()) |line| {
try testing.expect(std.mem.startsWith(u8, line, "FAIL config: "));
failures += 1;
}
try testing.expectEqual(@as(usize, 2), failures);
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "error: unexpected field 'grops'"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "note: supported: "));
}
test "check reads config.db without writing to it" { test "check reads config.db without writing to it" {
// D6: the database branch opened read/write, chmod'ed 0600, turned WAL on — // D6: the database branch opened read/write, chmod'ed 0600, turned WAL on —
// which is what creates the two sidecars — and committed migration steps, // which is what creates the two sidecars — and committed migration steps,
+30 -1
View File
@@ -164,7 +164,11 @@ pub fn importSource(
/// `std.zon.parse.Diagnostics` renders one "line:column: error: text" line per /// `std.zon.parse.Diagnostics` renders one "line:column: error: text" line per
/// problem, plus a "note:" line each, so each rendered line becomes one /// problem, plus a "note:" line each, so each rendered line becomes one
/// `Problem` and the list keeps the parser's order. /// `Problem` and the list keeps the parser's order.
fn reportParseFailure( ///
/// `pub` because `nxdns check` parses the same file and owes the operator the
/// same one-line-per-problem output; rendering the ZON diagnostics inline would
/// put newlines inside a single `FAIL` record.
pub fn reportParseFailure(
diags: *validate.Diagnostics, diags: *validate.Diagnostics,
zon_diag: *const std.zon.parse.Diagnostics, zon_diag: *const std.zon.parse.Diagnostics,
) error{OutOfMemory}!void { ) error{OutOfMemory}!void {
@@ -910,6 +914,31 @@ test "importSource reports a ZON syntax error and writes nothing" {
try testing.expect(std.mem.indexOf(u8, text, "1:14: error: ") != null); try testing.expect(std.mem.indexOf(u8, text, "1:14: error: ") != null);
} }
test "importSource splits a multi-line ZON failure into one problem per message" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
// An unexpected field renders as an "error:" line plus a "note:" line, so
// the rendering the CLI prints has to be split rather than embedded whole.
try testing.expectError(
error.ParseZon,
importSource(io, testing.allocator, &database, ".{ .grops = .{} }", .{}, &diags),
);
try testing.expectEqual(@as(usize, 2), diags.problems.items.len);
for (diags.problems.items) |problem| {
try testing.expectEqual(@as(?usize, null), std.mem.findScalar(u8, problem.message, '\n'));
}
try testing.expect(std.mem.indexOf(u8, diags.problems.items[0].message, "error: ") != null);
try testing.expect(std.mem.indexOf(u8, diags.problems.items[1].message, "note: ") != null);
}
test "a config omitting every optional field parses into an arena and leaks nothing" { test "a config omitting every optional field parses into an arena and leaks nothing" {
// The S5.1 rule as a test: `std.zon.parse.free` is never called, the arena // The S5.1 rule as a test: `std.zon.parse.free` is never called, the arena
// is the only release, and `std.testing.allocator` fails the test if a // is the only release, and `std.testing.allocator` fails the test if a
+47
View File
@@ -134,6 +134,34 @@ pub fn fromText(text: []const u8) FromTextError!Name {
return name; return name;
} }
pub const NormalizeError = error{BadName};
/// Lowercases over ASCII into `buf`, strips one trailing dot, and checks the
/// result is a name `fromText` accepts. Returns the normalized text, borrowed
/// from `buf`.
///
/// A byte ≥ 0x80 is rejected: query names arrive ASCII-lowercased, so a high
/// byte here could never be matched, and configuration that can never match is
/// worth reporting rather than storing. The root name is rejected for the same
/// reason — a configured entry that matches everything, or nothing, is not what
/// either caller means.
///
/// `filter/rules.zig`, `filter/compiler.zig` and `cache/dns_cache.zig` keep
/// their own variants on purpose; each says why beside its copy.
pub fn normalizeText(text: []const u8, buf: *[types.max_name_len]u8) NormalizeError![]const u8 {
var rest = text;
if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1];
if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName;
for (rest, 0..) |byte, i| {
if (byte >= 0x80) return error.BadName;
buf[i] = std.ascii.toLower(byte);
}
const normalized = buf[0..rest.len];
_ = fromText(normalized) catch return error.BadName;
return normalized;
}
/// Writes presentation form: labels joined by dots, no trailing dot. The root /// Writes presentation form: labels joined by dots, no trailing dot. The root
/// name writes as ".". /// name writes as ".".
pub fn formatText(name: Name, w: *Writer) Writer.Error!void { pub fn formatText(name: Name, w: *Writer) Writer.Error!void {
@@ -392,3 +420,22 @@ test "labelCount" {
try testing.expectEqual(@as(usize, 1), (try fromText("com")).labelCount()); try testing.expectEqual(@as(usize, 1), (try fromText("com")).labelCount());
try testing.expectEqual(@as(usize, 3), (try fromText("www.example.com")).labelCount()); try testing.expectEqual(@as(usize, 3), (try fromText("www.example.com")).labelCount());
} }
test "normalizeText lowercases and strips one trailing dot" {
var buf: [types.max_name_len]u8 = undefined;
try testing.expectEqualStrings("example.com", try normalizeText("Example.COM", &buf));
try testing.expectEqualStrings("example.com", try normalizeText("example.com.", &buf));
try testing.expectEqualStrings("a", try normalizeText("A", &buf));
}
test "normalizeText rejects the root, high bytes and names fromText refuses" {
var buf: [types.max_name_len]u8 = undefined;
try testing.expectError(error.BadName, normalizeText("", &buf));
try testing.expectError(error.BadName, normalizeText(".", &buf));
try testing.expectError(error.BadName, normalizeText("caf\xc3\xa9.example.com", &buf));
try testing.expectError(error.BadName, normalizeText("a..b", &buf));
try testing.expectError(error.BadName, normalizeText("a." ** 200 ++ "com", &buf));
// A label of 64 bytes is one over the wire limit.
try testing.expectError(error.BadName, normalizeText("a" ** 64 ++ ".com", &buf));
}
+9 -18
View File
@@ -61,30 +61,17 @@ pub fn compile(
var wild: Entries = .{}; var wild: Entries = .{};
defer wild.deinit(gpa); defer wild.deinit(gpa);
while (true) { while (try parsers.nextBoundedLine(r, max_line_len)) |event| {
const raw = r.takeDelimiter('\n') catch |err| switch (err) { const raw = switch (event) {
error.ReadFailed => return error.ReadFailed, .long_line => {
// `takeDelimiter` leaves the stream unmodified on `StreamTooLong`
// (Reader.zig:885). Without this discard the loop re-reads the same
// bytes forever.
error.StreamTooLong => {
counts.long_lines += 1; counts.long_lines += 1;
_ = r.discardDelimiterInclusive('\n') catch |discard_err| switch (discard_err) {
error.EndOfStream => break,
error.ReadFailed => return error.ReadFailed,
};
continue; continue;
}, },
} orelse break; .line => |line| line,
};
var line = raw; var line = raw;
if (line.len != 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; if (line.len != 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1];
// A reader whose buffer is larger than `max_line_len` reports the
// over-long line here instead of through `error.StreamTooLong`.
if (line.len > max_line_len) {
counts.long_lines += 1;
continue;
}
const parsed = parsers.parseLine(format, line); const parsed = parsers.parseLine(format, line);
switch (parsed.kind) { switch (parsed.kind) {
@@ -120,6 +107,10 @@ pub fn compile(
/// Normalizes one whitespace-separated candidate and files it under `.list`, /// Normalizes one whitespace-separated candidate and files it under `.list`,
/// `.wild`, or neither. /// `.wild`, or neither.
///
/// The normalization below is deliberately not `dns.name.normalizeText`: this
/// one adds the two-label minimum, rejects control bytes, and reports every
/// rejection through `counts.invalid` rather than an error.
fn addCandidate( fn addCandidate(
gpa: std.mem.Allocator, gpa: std.mem.Allocator,
field: []const u8, field: []const u8,
+5 -13
View File
@@ -1530,20 +1530,12 @@ fn destroySnapshot(gpa: Allocator, snapshot: *matcher.Snapshot) void {
fn collectSample(r: *std.Io.Reader, w: *std.Io.Writer) error{ ReadFailed, WriteFailed }!void { fn collectSample(r: *std.Io.Reader, w: *std.Io.Writer) error{ ReadFailed, WriteFailed }!void {
var considered: usize = 0; var considered: usize = 0;
while (considered < parsers.sample_lines) { while (considered < parsers.sample_lines) {
const raw = r.takeDelimiter('\n') catch |err| switch (err) { const event = (try parsers.nextBoundedLine(r, compiler.max_line_len)) orelse return;
// The stream is left unmodified here, so the line has to be stepped const raw = switch (event) {
// over or this loop never advances. .long_line => continue,
error.StreamTooLong => { .line => |line| line,
_ = r.discardDelimiterInclusive('\n') catch |discard_err| switch (discard_err) { };
error.EndOfStream => return,
error.ReadFailed => return error.ReadFailed,
};
continue;
},
error.ReadFailed => return error.ReadFailed,
} orelse return;
if (raw.len > compiler.max_line_len) continue;
const line = std.mem.trim(u8, raw, &std.ascii.whitespace); const line = std.mem.trim(u8, raw, &std.ascii.whitespace);
if (line.len == 0) continue; if (line.len == 0) continue;
if (parsers.isComment(line)) continue; if (parsers.isComment(line)) continue;
+96
View File
@@ -48,6 +48,50 @@ pub fn parseLine(format: Format, line: []const u8) Line {
}; };
} }
pub const LineEvent = union(enum) {
/// One line without its delimiter, borrowed from the reader's buffer and
/// valid only until the next call. A trailing '\r' is left on: whether it
/// belongs to the line is the caller's decision.
line: []const u8,
/// A line longer than `max_len`. It has already been stepped over.
long_line,
};
/// One line, or `null` at end of stream. `max_len` bounds a line; anything
/// longer comes back as `.long_line` with the stream positioned on the line
/// after it, so a caller that keeps calling always advances.
///
/// The bound is a parameter because this file may not import `compiler.zig`:
/// the compiler imports this one, and this file is the root of a separate fuzz
/// module. Both callers pass `compiler.max_line_len`.
///
/// The two over-long paths exist because a `Reader` reports an over-long line
/// two different ways. A reader whose buffer is smaller than `max_len` reports
/// `error.StreamTooLong` and — this is the hazard — leaves the stream
/// unmodified (Reader.zig:895-919), so without the discard a caller re-reads
/// the same bytes forever. A reader whose buffer is larger hands the whole line
/// over and the length check catches it.
///
/// An over-long final line with no delimiter ends the stream inside the
/// discard. That still counts as a line, so it comes back as `.long_line`; the
/// discard drained the stream (Reader.zig:1042), so the next call returns
/// `null`.
pub fn nextBoundedLine(r: *std.Io.Reader, max_len: usize) error{ReadFailed}!?LineEvent {
const raw = r.takeDelimiter('\n') catch |err| switch (err) {
error.ReadFailed => return error.ReadFailed,
error.StreamTooLong => {
_ = r.discardDelimiterInclusive('\n') catch |discard_err| switch (discard_err) {
error.EndOfStream => return .long_line,
error.ReadFailed => return error.ReadFailed,
};
return .long_line;
},
} orelse return null;
if (raw.len > max_len) return .long_line;
return .{ .line = raw };
}
pub const sample_lines = 64; pub const sample_lines = 64;
/// Picks a format from the first `sample_lines` lines that are not blank and /// Picks a format from the first `sample_lines` lines that are not blank and
@@ -309,6 +353,58 @@ test "parseLine dispatches to the abp parser" {
try testing.expect(line.covers_apex); try testing.expect(line.covers_apex);
} }
fn expectLine(expected: []const u8, event: ?LineEvent) !void {
const got = event orelse return error.TestExpectedLine;
switch (got) {
.line => |line| try testing.expectEqualStrings(expected, line),
.long_line => return error.TestExpectedLine,
}
}
test "nextBoundedLine walks lines and ends at the stream" {
var r: std.Io.Reader = .fixed("a\nbb\n\nccc");
try expectLine("a", try nextBoundedLine(&r, 16));
try expectLine("bb", try nextBoundedLine(&r, 16));
try expectLine("", try nextBoundedLine(&r, 16));
// A final line with no delimiter is still a line.
try expectLine("ccc", try nextBoundedLine(&r, 16));
try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(&r, 16));
}
test "nextBoundedLine reports an over-long line when the reader buffer is large" {
var r: std.Io.Reader = .fixed("a\nxxxxxxxx\nb\n");
try expectLine("a", try nextBoundedLine(&r, 4));
try testing.expectEqual(LineEvent.long_line, (try nextBoundedLine(&r, 4)).?);
try expectLine("b", try nextBoundedLine(&r, 4));
try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(&r, 4));
}
test "nextBoundedLine steps over a line that does not fit the reader buffer" {
// A buffer smaller than the long line makes `takeDelimiter` report
// `error.StreamTooLong` and leave the stream where it was, which is the
// path that loops forever without the discard.
var backing: std.Io.Reader = .fixed("a\n" ++ "x" ** 64 ++ "\nb\n");
var buf: [16]u8 = undefined;
var limited = backing.limited(.unlimited, &buf);
const r = &limited.interface;
try expectLine("a", try nextBoundedLine(r, 16));
try testing.expectEqual(LineEvent.long_line, (try nextBoundedLine(r, 16)).?);
try expectLine("b", try nextBoundedLine(r, 16));
try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(r, 16));
}
test "nextBoundedLine reports an over-long final line that ends inside the discard" {
var backing: std.Io.Reader = .fixed("a\n" ++ "x" ** 64);
var buf: [16]u8 = undefined;
var limited = backing.limited(.unlimited, &buf);
const r = &limited.interface;
try expectLine("a", try nextBoundedLine(r, 16));
try testing.expectEqual(LineEvent.long_line, (try nextBoundedLine(r, 16)).?);
try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(r, 16));
}
test "looksLikeIpLiteral separates addresses from names" { test "looksLikeIpLiteral separates addresses from names" {
try testing.expect(looksLikeIpLiteral("0.0.0.0")); try testing.expect(looksLikeIpLiteral("0.0.0.0"));
try testing.expect(looksLikeIpLiteral("127.0.0.1")); try testing.expect(looksLikeIpLiteral("127.0.0.1"));
+4
View File
@@ -192,6 +192,10 @@ const NameError = error{BadName};
/// Lowercases over ASCII and strips one trailing dot. A byte ≥ 0x80 is /// Lowercases over ASCII and strips one trailing dot. A byte ≥ 0x80 is
/// rejected: query names reach the matcher ASCII-lowercased, so a pattern /// rejected: query names reach the matcher ASCII-lowercased, so a pattern
/// carrying a high byte could never match anything. /// carrying a high byte could never match anything.
///
/// Deliberately not `dns.name.normalizeText`: a pattern may hold `*`, which
/// `name.fromText` would reject, so this variant skips that check and rejects
/// control bytes and space instead.
fn normalize(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 { fn normalize(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 {
var rest = text; var rest = text;
if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1]; if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1];
+15 -62
View File
@@ -135,13 +135,13 @@ pub const ForwardClient = struct {
const socket = local.bind(io, .{ .mode = .dgram }) catch |err| { const socket = local.bind(io, .{ .mode = .dgram }) catch |err| {
log.debug("forward resolver: udp bind failed: {s}", .{@errorName(err)}); log.debug("forward resolver: udp bind failed: {s}", .{@errorName(err)});
return mapPhase(err, error.ConnectFailed); return transport.mapPhase(err, error.ConnectFailed);
}; };
defer closeSocket(io, &socket); defer transport.closeBlocked(io, &socket);
socket.send(io, &dest, query) catch |err| { socket.send(io, &dest, query) catch |err| {
log.debug("forward resolver: udp send failed: {s}", .{@errorName(err)}); log.debug("forward resolver: udp send failed: {s}", .{@errorName(err)});
return mapPhase(err, error.SendFailed); return transport.mapPhase(err, error.SendFailed);
}; };
// A deadline, not a duration: a discarded foreign datagram restarts the // A deadline, not a duration: a discarded foreign datagram restarts the
@@ -152,7 +152,7 @@ pub const ForwardClient = struct {
const msg = socket.receiveTimeout(io, response_buf, deadline) catch |err| switch (err) { const msg = socket.receiveTimeout(io, response_buf, deadline) catch |err| switch (err) {
error.Timeout => return error.Timeout, error.Timeout => return error.Timeout,
error.ConcurrencyUnavailable => return error.SystemResources, error.ConcurrencyUnavailable => return error.SystemResources,
else => return mapPhase(err, error.ReceiveFailed), else => return transport.mapPhase(err, error.ReceiveFailed),
}; };
// Off-path spoofing is the reason the source address is checked at // Off-path spoofing is the reason the source address is checked at
@@ -183,35 +183,16 @@ pub const ForwardClient = struct {
} }
} }
/// No stream read or write in 0.16.0 takes a timeout, so the budget is a /// The read budget bounds the whole TCP exchange through
/// second task and the loser is canceled. `ConnectOptions.timeout` is never /// `transport.raceWithin`. `ConnectOptions.timeout` is never set: the
/// set: the Threaded backend panics on it (Threaded.zig:12076). /// Threaded backend panics on it (Threaded.zig:12076).
fn exchangeTcp( fn exchangeTcp(
self: *ForwardClient, self: *ForwardClient,
io: std.Io, io: std.Io,
query: []const u8, query: []const u8,
response_buf: []u8, response_buf: []u8,
) transport.ExchangeError![]u8 { ) transport.ExchangeError![]u8 {
var outcomes: [2]Outcome = undefined; return transport.raceWithin(io, self.read_timeout, tcpOnce, .{ self, io, query, response_buf });
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
defer race.cancelDiscard();
race.concurrent(.exchange, tcpOnce, .{ self, io, query, response_buf }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
race.concurrent(.expiry, expire, .{ io, self.read_timeout }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
switch (try race.await()) {
.exchange => |result| return result,
.expiry => |result| {
// A canceled sleep means this whole task is being torn down,
// not that the resolver is slow.
try result;
return error.Timeout;
},
}
} }
fn tcpOnce( fn tcpOnce(
@@ -224,9 +205,9 @@ pub const ForwardClient = struct {
const stream = dest.connect(io, .{ .mode = .stream }) catch |err| { const stream = dest.connect(io, .{ .mode = .stream }) catch |err| {
log.debug("forward resolver: tcp connect failed: {s}", .{@errorName(err)}); log.debug("forward resolver: tcp connect failed: {s}", .{@errorName(err)});
return mapPhase(err, error.ConnectFailed); return transport.mapPhase(err, error.ConnectFailed);
}; };
defer closeStream(io, &stream); defer transport.closeBlocked(io, &stream);
const split = self.frame_buf.len / 2; const split = self.frame_buf.len / 2;
var stream_writer = stream.writer(io, self.frame_buf[0..split]); var stream_writer = stream.writer(io, self.frame_buf[0..split]);
@@ -257,15 +238,6 @@ pub const ForwardClient = struct {
} }
}; };
const Outcome = union(enum) {
exchange: transport.ExchangeError![]u8,
expiry: std.Io.Cancelable!void,
};
fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void {
return duration.sleep(io);
}
/// The local address a datagram to `dest` is sent from: same family, port /// The local address a datagram to `dest` is sent from: same family, port
/// chosen by the kernel. /// chosen by the kernel.
fn wildcardFor(dest: net.IpAddress) net.IpAddress { fn wildcardFor(dest: net.IpAddress) net.IpAddress {
@@ -275,25 +247,6 @@ fn wildcardFor(dest: net.IpAddress) net.IpAddress {
}; };
} }
/// The TCP budget cancels the exchange task. The next cancelable `Io` call in
/// the `defer` chain would then return `error.Canceled` and skip the close,
/// leaking the descriptor, so both closes run with cancellation blocked.
fn closeStream(io: std.Io, stream: *const net.Stream) void {
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
stream.close(io);
}
fn closeSocket(io: std.Io, socket: *const net.Socket) void {
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
socket.close(io);
}
fn mapPhase(err: anyerror, phase: transport.PeerFault) transport.ExchangeError {
return transport.mapLocal(err) orelse phase;
}
/// `Io.Writer` collapses everything to `error.WriteFailed` and stashes the /// `Io.Writer` collapses everything to `error.WriteFailed` and stashes the
/// cause. Unwrapping it is what keeps `error.Canceled` and the local resource /// cause. Unwrapping it is what keeps `error.Canceled` and the local resource
/// errors out of the peer fault group. /// errors out of the peer fault group.
@@ -302,7 +255,7 @@ fn sendFailure(stream_writer: *const net.Stream.Writer, err: anyerror) transport
stream_writer.err.? stream_writer.err.?
else else
err; err;
return mapPhase(cause, error.SendFailed); return transport.mapPhase(cause, error.SendFailed);
} }
fn receiveFailure(stream_reader: *const net.Stream.Reader, err: anyerror) transport.ExchangeError { fn receiveFailure(stream_reader: *const net.Stream.Reader, err: anyerror) transport.ExchangeError {
@@ -310,7 +263,7 @@ fn receiveFailure(stream_reader: *const net.Stream.Reader, err: anyerror) transp
stream_reader.err.? stream_reader.err.?
else else
err; err;
return mapPhase(cause, error.ReceiveFailed); return transport.mapPhase(cause, error.ReceiveFailed);
} }
const testing = std.testing; const testing = std.testing;
@@ -408,19 +361,19 @@ test "mapPhase keeps local resource and cancellation errors out of the peer faul
for (local) |err| { for (local) |err| {
try testing.expectEqual( try testing.expectEqual(
transport.Group.local_resource, transport.Group.local_resource,
transport.group(mapPhase(err, error.ReceiveFailed)), transport.group(transport.mapPhase(err, error.ReceiveFailed)),
); );
} }
try testing.expectEqual( try testing.expectEqual(
transport.ExchangeError.Canceled, transport.ExchangeError.Canceled,
mapPhase(error.Canceled, error.ConnectFailed), transport.mapPhase(error.Canceled, error.ConnectFailed),
); );
// A refused connection is the resolver's side, so it stays a peer fault. // A refused connection is the resolver's side, so it stays a peer fault.
try testing.expectEqual( try testing.expectEqual(
transport.ExchangeError.ConnectFailed, transport.ExchangeError.ConnectFailed,
mapPhase(error.ConnectionRefused, error.ConnectFailed), transport.mapPhase(error.ConnectionRefused, error.ConnectFailed),
); );
} }
+4 -22
View File
@@ -48,7 +48,10 @@ pub const Zones = struct {
var buf: [types.max_name_len]u8 = undefined; var buf: [types.max_name_len]u8 = undefined;
for (rows) |row| { for (rows) |row| {
const zone = normalizeName(row.zone, &buf) catch return error.BadZone; // The root is rejected with everything else `normalizeText` refuses:
// a zone that forwards everything would bypass the upstream pool
// entirely, which is not what conditional forwarding means.
const zone = name.normalizeText(row.zone, &buf) catch return error.BadZone;
const resolver = validate.parseResolver(row.resolver) catch return error.BadResolver; const resolver = validate.parseResolver(row.resolver) catch return error.BadResolver;
try spans.append(gpa, .{ try spans.append(gpa, .{
.offset = names.items.len, .offset = names.items.len,
@@ -119,27 +122,6 @@ fn suffixMatches(zone: []const u8, domain: []const u8) bool {
return domain[start - 1] == '.' and std.mem.eql(u8, domain[start..], zone); return domain[start - 1] == '.' and std.mem.eql(u8, domain[start..], zone);
} }
const NameError = error{BadName};
/// Lowercases over ASCII, strips one trailing dot, and checks the result is a
/// name `dns.name.fromText` accepts. A byte ≥ 0x80 is rejected because query
/// names arrive ASCII-lowercased, so a high byte could never match. The root
/// zone is rejected too: a zone that forwards everything would bypass the
/// upstream pool entirely, which is not what conditional forwarding means.
fn normalizeName(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 {
var rest = text;
if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1];
if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName;
for (rest, 0..) |byte, i| {
if (byte >= 0x80) return error.BadName;
buf[i] = std.ascii.toLower(byte);
}
const normalized = buf[0..rest.len];
_ = name.fromText(normalized) catch return error.BadName;
return normalized;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+4 -23
View File
@@ -52,7 +52,9 @@ pub const Records = struct {
var buf: [types.max_name_len]u8 = undefined; var buf: [types.max_name_len]u8 = undefined;
for (rows) |row| { for (rows) |row| {
const owner = normalizeName(row.name, &buf) catch return error.BadRecordName; // A record whose owner can never be matched — a high byte, or the
// root — is a configuration error, not a record that never answers.
const owner = name.normalizeText(row.name, &buf) catch return error.BadRecordName;
const value = try parseValue(row.rtype, row.value); const value = try parseValue(row.rtype, row.value);
try spans.append(gpa, .{ try spans.append(gpa, .{
.offset = owners.items.len, .offset = owners.items.len,
@@ -187,27 +189,6 @@ fn rankRun(records: []const Record, wanted: u2) []const Record {
return records[start..end]; return records[start..end];
} }
const NameError = error{BadName};
/// Lowercases over ASCII, strips one trailing dot, and checks the result is a
/// name `dns.name.fromText` accepts. A byte ≥ 0x80 is rejected: query names
/// arrive ASCII-lowercased, so a high byte here could never be matched and a
/// record that can never answer is a configuration error worth reporting. The
/// root name is rejected for the same reason — nothing can match it.
fn normalizeName(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 {
var rest = text;
if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1];
if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName;
for (rest, 0..) |byte, i| {
if (byte >= 0x80) return error.BadName;
buf[i] = std.ascii.toLower(byte);
}
const normalized = buf[0..rest.len];
_ = name.fromText(normalized) catch return error.BadName;
return normalized;
}
fn parseValue(rtype: model.RecordType, text: []const u8) error{BadRecordValue}!Value { fn parseValue(rtype: model.RecordType, text: []const u8) error{BadRecordValue}!Value {
switch (rtype) { switch (rtype) {
.a => { .a => {
@@ -226,7 +207,7 @@ fn parseValue(rtype: model.RecordType, text: []const u8) error{BadRecordValue}!V
}, },
.cname => { .cname => {
var buf: [types.max_name_len]u8 = undefined; var buf: [types.max_name_len]u8 = undefined;
const target = normalizeName(text, &buf) catch return error.BadRecordValue; const target = name.normalizeText(text, &buf) catch return error.BadRecordValue;
return .{ .cname = name.fromText(target) catch return error.BadRecordValue }; return .{ .cname = name.fromText(target) catch return error.BadRecordValue };
}, },
} }
+97 -381
View File
@@ -1,14 +1,13 @@
//! The DoH listener (RFC 8484 over HTTP/1.1 + TLS, milestone-10 ruling 2). //! The DoH listener (RFC 8484 over HTTP/1.1 + TLS, milestone-10 ruling 2).
//! //!
//! The shape is web/server.zig's: one `std.http.Server` per connection over our //! The shape is web/server.zig's: one `std.http.Server` per connection over the
//! own accept loop, fixed pre-allocated connection slots, a keep-alive loop per //! shared `listener.Core` accept loop, fixed pre-allocated connection slots, and
//! connection that ends on `error.HttpConnectionClosing`, and the same shutdown //! a keep-alive loop per connection that ends on
//! split — `deinit` shuts live connections down and drains, a canceled `serve` //! `error.HttpConnectionClosing`. The difference is the transport: after the TCP
//! cancels the connection group because HTTP keep-alive has no deadline of its //! accept, a certificate generation is pinned (`CertStore.acquire`) and
//! own. The difference is the transport: after the TCP accept, a certificate //! `ServerStream.accept` runs the TLS handshake through
//! generation is pinned (`CertStore.acquire`) and `ServerStream.accept` runs the //! `listener.handshakeStage`, and `std.http.Server` sits on the stream's
//! TLS handshake, and `std.http.Server` sits on the stream's plaintext //! plaintext reader/writer (http/Server.zig:25 takes arbitrary interfaces).
//! reader/writer (http/Server.zig:25 takes arbitrary interfaces).
//! //!
//! The handshake runs under the same race budget tcp_server applies to its //! The handshake runs under the same race budget tcp_server applies to its
//! reads (ruling 3's rationale): a client that connects and never handshakes //! reads (ruling 3's rationale): a client that connects and never handshakes
@@ -33,12 +32,11 @@ const address = @import("../platform/address.zig");
const cert_store = @import("cert_store.zig"); const cert_store = @import("cert_store.zig");
const doh_client = @import("../upstream/doh_client.zig"); const doh_client = @import("../upstream/doh_client.zig");
const handler = @import("handler.zig"); const handler = @import("handler.zig");
const listener = @import("listener.zig");
const model = @import("../config/model.zig"); const model = @import("../config/model.zig");
const tls_server = @import("../platform/tls_server.zig"); const tls_server = @import("../platform/tls_server.zig");
const transport = @import("../upstream/transport.zig"); const transport = @import("../upstream/transport.zig");
const log = std.log.scoped(.doh_server);
pub const dns_query_path = "/dns-query"; pub const dns_query_path = "/dns-query";
/// Ruling 5. Mbed TLS records the pointer, so the list must outlive every /// Ruling 5. Mbed TLS records the pointer, so the list must outlive every
@@ -55,10 +53,6 @@ const send_buffer_len = 4 * 1024;
pub const default_max_connections: u16 = 64; pub const default_max_connections: u16 = 64;
/// 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 allow_header: http.Header = .{ .name = "allow", .value = "GET, POST" }; const allow_header: http.Header = .{ .name = "allow", .value = "GET, POST" };
pub const Options = struct { pub const Options = struct {
@@ -71,18 +65,9 @@ pub const Options = struct {
idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake }, idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake },
}; };
/// What DoH counts on top of `listener.CoreStats`.
pub const Stats = struct { pub const Stats = struct {
connections: 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),
tls_handshake_failures: std.atomic.Value(u64) = .init(0), tls_handshake_failures: std.atomic.Value(u64) = .init(0),
/// Keep-alive connections reclaimed after `idle_timeout` elapsed with no
/// request head on the wire. A stalled handshake counts as a handshake
/// failure instead (milestone-16 ruling 9), so this name means only what
/// it says.
idle_timeouts: std.atomic.Value(u64) = .init(0),
connection_errors: std.atomic.Value(u64) = .init(0),
/// Every 4xx answered on `/dns-query` and every miss beside it: the /// Every 4xx answered on `/dns-query` and every miss beside it: the
/// visibility counter for clients that speak, but speak wrongly. /// visibility counter for clients that speak, but speak wrongly.
bad_requests: std.atomic.Value(u64) = .init(0), bad_requests: std.atomic.Value(u64) = .init(0),
@@ -94,53 +79,28 @@ pub const Snapshot = struct {
rejected_at_shutdown: u64, rejected_at_shutdown: u64,
accept_errors: u64, accept_errors: u64,
tls_handshake_failures: u64, tls_handshake_failures: u64,
/// Keep-alive connections reclaimed after `idle_timeout` elapsed with no
/// request head on the wire. A stalled handshake counts as a handshake
/// failure instead (milestone-16 ruling 9), so this name means only what
/// it says.
idle_timeouts: u64, idle_timeouts: u64,
connection_errors: u64, connection_errors: u64,
bad_requests: u64, bad_requests: u64,
}; };
/// 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 DohServer = struct { pub const DohServer = struct {
/// Allocates the per-connection Mbed TLS context in `ServerStream.accept`. core: listener.Core(Config),
gpa: Allocator,
handler: *handler.Handler, handler: *handler.Handler,
certs: *cert_store.CertStore, certs: *cert_store.CertStore,
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,
options: Options, options: Options,
stats: Stats, stats: Stats,
run_state: std.atomic.Value(State),
stopped: std.Io.Event,
/// One slot is ~150 KiB, so the default 64 connections cost ~9.4 MiB. The /// One slot is ~150 KiB, so the default 64 connections cost ~9.4 MiB. The
/// two message buffers cannot shrink: a POST body and the reply both go up /// two message buffers cannot shrink: a POST body and the reply both go up
/// to the 65535 bytes a DNS message can be. /// to the 65535 bytes a DNS message can be. The `ServerStream` plaintext
pub const Conn = struct { /// buffers belong to the core; its `read_buf` doubles as the HTTP head cap
/// `ServerStream` plaintext buffers; `read_buf` doubles as the HTTP /// (see `recv_buffer_len`).
/// head cap (see `recv_buffer_len`). pub const Payload = struct {
read_buf: [recv_buffer_len]u8,
write_buf: [send_buffer_len]u8,
/// The decoded query: a POST body or a GET `dns` parameter. /// The decoded query: a POST body or a GET `dns` parameter.
query: [transport.max_message_len]u8, query: [transport.max_message_len]u8,
reply: [transport.max_message_len]u8, reply: [transport.max_message_len]u8,
@@ -148,15 +108,22 @@ pub const DohServer = struct {
/// serially, so one query uses it at a time. /// serially, so one query uses it at a time.
scratch: handler.Scratch, scratch: handler.Scratch,
/// Valid between a successful `ServerStream.accept` and the /// Valid between a successful `ServerStream.accept` and the
/// `close(gpa)` in `serveConn`'s defer. /// `close(gpa)` in `serveOne`'s defer.
tls: tls_server.ServerStream, tls: tls_server.ServerStream,
stream: net.Stream,
peer: net.IpAddress,
/// Guarded by `DohServer.mutex`.
conn_state: ConnState,
}; };
pub const ListenError = net.IpAddress.ListenError || error{OutOfMemory}; const Config = struct {
pub const Owner = DohServer;
pub const ConnPayload = Payload;
pub const serveConn = serveOne;
pub const read_buffer_len = recv_buffer_len;
pub const write_buffer_len = send_buffer_len;
pub const log = std.log.scoped(.doh_server);
pub const name = "doh";
};
pub const Conn = listener.Core(Config).Conn;
pub const ListenError = listener.Core(Config).ListenError;
pub fn listen( pub fn listen(
gpa: Allocator, gpa: Allocator,
@@ -166,172 +133,75 @@ pub const DohServer = struct {
certs: *cert_store.CertStore, certs: *cert_store.CertStore,
options: Options, options: Options,
) ListenError!DohServer { ) ListenError!DohServer {
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;
const listener = try listen_address.listen(io, .{ .reuse_address = true });
return .{ return .{
.gpa = gpa, .core = try listener.Core(Config).listen(gpa, io, listen_address, options.max_connections),
.handler = h, .handler = h,
.certs = certs, .certs = certs,
.listener = listener,
.conns = conns,
.mutex = .init,
.shutdown_begun = false,
.options = options, .options = options,
.stats = .{}, .stats = .{},
.run_state = .init(.idle),
.stopped = .unset,
}; };
} }
/// The kernel-assigned address. A port of 0 in `listen` resolves here. /// The kernel-assigned address. A port of 0 in `listen` resolves here.
pub fn boundAddress(self: *const DohServer) net.IpAddress { pub fn boundAddress(self: *const DohServer) net.IpAddress {
return self.listener.socket.address; return self.core.boundAddress();
} }
pub fn snapshotStats(self: *const DohServer) Snapshot { pub fn snapshotStats(self: *const DohServer) Snapshot {
const core = &self.core.stats;
return .{ return .{
.connections = self.stats.connections.load(.monotonic), .connections = core.connections.load(.monotonic),
.rejected_at_capacity = self.stats.rejected_at_capacity.load(.monotonic), .rejected_at_capacity = core.rejected_at_capacity.load(.monotonic),
.rejected_at_shutdown = self.stats.rejected_at_shutdown.load(.monotonic), .rejected_at_shutdown = core.rejected_at_shutdown.load(.monotonic),
.accept_errors = self.stats.accept_errors.load(.monotonic), .accept_errors = core.accept_errors.load(.monotonic),
.tls_handshake_failures = self.stats.tls_handshake_failures.load(.monotonic), .tls_handshake_failures = self.stats.tls_handshake_failures.load(.monotonic),
.idle_timeouts = self.stats.idle_timeouts.load(.monotonic), .idle_timeouts = core.idle_timeouts.load(.monotonic),
.connection_errors = self.stats.connection_errors.load(.monotonic), .connection_errors = core.connection_errors.load(.monotonic),
.bad_requests = self.stats.bad_requests.load(.monotonic), .bad_requests = self.stats.bad_requests.load(.monotonic),
}; };
} }
/// Accept loop. Returns when the task is canceled or `deinit` stops it. /// Accept loop. Returns when the task is canceled or `deinit` stops it.
pub fn serve(self: *DohServer, io: std.Io) void { pub fn serve(self: *DohServer, io: std.Io) void {
if (self.run_state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return; self.core.serve(io);
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: *DohServer, gpa: Allocator, io: std.Io) void { pub fn deinit(self: *DohServer, io: std.Io) void {
const was_serving = self.run_state.swap(.closing, .acq_rel) == .serving; self.core.deinit(io);
// 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("doh listener shutdown failed: {t}", .{err});
};
self.beginShutdown(io);
if (was_serving) self.stopped.waitUncancelable(io);
self.listener.deinit(io);
gpa.free(self.conns);
self.* = undefined; self.* = undefined;
} }
fn acceptLoop(self: *DohServer, io: std.Io, group: *std.Io.Group) Stop { /// One connection: pin, handshake, keep-alive loop, close_notify, release —
while (self.run_state.load(.acquire) == .serving) { /// the ordering `listener.handshakeStage` documents. The core closes the
const stream = self.listener.accept(io) catch |err| switch (err) { /// TCP stream after this returns.
error.Canceled => return .canceled, fn serveOne(self: *DohServer, io: std.Io, index: usize) void {
error.SocketNotListening => return .closing, const conn = &self.core.conns[index];
else => { const stats = &self.core.stats;
bump(&self.stats.accept_errors); const gpa = self.core.gpa;
log.debug("doh accept failed: {t}", .{err});
retry_delay.sleep(io) catch return .canceled;
continue;
},
};
const index = switch (self.claim(io, stream)) {
.slot => |index| index,
// See the module comment: no 503 without a handshake, so over
// capacity the stream is closed raw and the refusal counted.
.at_capacity => {
bump(&self.stats.rejected_at_capacity);
stream.close(io);
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.connections);
}
// The loop condition failed, which only `deinit` can cause.
return .closing;
}
fn serveConn(self: *DohServer, io: std.Io, index: usize) void {
defer self.finish(io, index);
const conn = &self.conns[index];
// Pinned for the whole connection (ruling 6): a reload never frees the // Pinned for the whole connection (ruling 6): a reload never frees the
// generation this stream handshook against. // generation this stream handshook against.
const entry = self.certs.acquire(io); const entry = self.certs.acquire(io);
defer self.certs.release(io, entry); defer self.certs.release(io, entry);
var handshook = false; const stage: Handshake = .{ .conn = conn, .gpa = gpa, .ctx = &entry.ctx, .io = io };
switch (race(io, self.options.idle_timeout, handshake, .{ self.gpa, conn, &entry.ctx, io, &handshook })) { switch (listener.handshakeStage(io, self.options.idle_timeout, stage)) {
.ok => {}, .ok => {},
// The select can report the expiry or the cancellation after the .canceled => return,
// handshake has in fact succeeded. The flag is written before the
// race joins its tasks, so a TLS context that exists is closed on
// every path, exactly once.
.canceled => {
if (handshook) conn.tls.close(self.gpa);
return;
},
// Milestone-16 ruling 9: a stalled handshake is refused like a broken // Milestone-16 ruling 9: a stalled handshake is refused like a broken
// one, the DoT arrangement. `idle_timeouts` belongs to the keep-alive // one, the DoT arrangement. `idle_timeouts` belongs to the keep-alive
// wait below, so the two listeners export the same names for the // wait below, so the two listeners export the same names for the
// same events. // same events.
.timed_out, .failed => { .timed_out, .failed => {
if (handshook) conn.tls.close(self.gpa); listener.bump(&self.stats.tls_handshake_failures);
bump(&self.stats.tls_handshake_failures);
return; return;
}, },
} }
// Flushes, sends close_notify and frees the TLS context on every exit // Flushes, sends close_notify and frees the TLS context on every exit
// path below; `finish` closes the TCP stream afterwards. // path below; the core closes the TCP stream afterwards.
defer conn.tls.close(self.gpa); defer conn.payload.tls.close(gpa);
var connection: http.Server = .init(conn.tls.reader(), conn.tls.writer()); var connection: http.Server = .init(conn.payload.tls.reader(), conn.payload.tls.writer());
while (connection.reader.state == .ready) { while (connection.reader.state == .ready) {
// Milestone-16 ruling 10: the wait for the next request head is the // Milestone-16 ruling 10: the wait for the next request head is the
@@ -339,10 +209,10 @@ pub const DohServer = struct {
// it runs under the same budget as the handshake. The body read and // it runs under the same budget as the handshake. The body read and
// `handleRequest` below stay untimed. // `handleRequest` below stay untimed.
var head: ReceiveHeadResult = error.ReadFailed; var head: ReceiveHeadResult = error.ReadFailed;
switch (race(io, self.options.idle_timeout, receiveHeadInto, .{ &connection, &head })) { switch (listener.race(io, self.options.idle_timeout, receiveHeadInto, .{ &connection, &head })) {
.ok => {}, .ok => {},
.timed_out => { .timed_out => {
bump(&self.stats.idle_timeouts); listener.bump(&stats.idle_timeouts);
return; return;
}, },
// Cancellation is shutdown; `.failed` here is only the wrapper // Cancellation is shutdown; `.failed` here is only the wrapper
@@ -359,7 +229,7 @@ pub const DohServer = struct {
error.HttpRequestTruncated, error.HttpRequestTruncated,
error.HttpHeadersInvalid, error.HttpHeadersInvalid,
=> { => {
bump(&self.stats.connection_errors); listener.bump(&stats.connection_errors);
return; return;
}, },
}; };
@@ -380,7 +250,7 @@ pub const DohServer = struct {
// The peer went away mid-response. Normal. // The peer went away mid-response. Normal.
error.WriteFailed => return, error.WriteFailed => return,
error.HttpExpectationFailed, error.ReadFailed => { error.HttpExpectationFailed, error.ReadFailed => {
bump(&self.stats.connection_errors); listener.bump(&stats.connection_errors);
return; return;
}, },
}; };
@@ -392,6 +262,31 @@ pub const DohServer = struct {
} }
} }
/// The `listener.handshakeStage` stage: everything one mbedTLS handshake
/// needs, plus the close that undoes it.
const Handshake = struct {
conn: *Conn,
gpa: Allocator,
ctx: *tls_server.ServerContext,
io: std.Io,
pub fn accept(self: Handshake) anyerror!void {
const conn = self.conn;
try conn.payload.tls.accept(
self.gpa,
self.ctx,
self.io,
&conn.stream,
&conn.read_buf,
&conn.write_buf,
);
}
pub fn close(self: Handshake) void {
self.conn.payload.tls.close(self.gpa);
}
};
const HandleError = error{ WriteFailed, HttpExpectationFailed, ReadFailed }; const HandleError = error{ WriteFailed, HttpExpectationFailed, ReadFailed };
/// What `serveConn`'s keep-alive loop does after the response went out. /// What `serveConn`'s keep-alive loop does after the response went out.
@@ -444,7 +339,7 @@ pub const DohServer = struct {
return self.refuse(request, .bad_request, "bad request\n", &.{}, true); return self.refuse(request, .bad_request, "bad request\n", &.{}, true);
}, },
}; };
const query = decodeDnsValue(value, &conn.query) catch { const query = decodeDnsValue(value, &conn.payload.query) catch {
return self.refuse(request, .bad_request, "bad request\n", &.{}, true); return self.refuse(request, .bad_request, "bad request\n", &.{}, true);
}; };
return self.answer(io, conn, request, query); return self.answer(io, conn, request, query);
@@ -459,17 +354,17 @@ pub const DohServer = struct {
return self.refuse(request, .payload_too_large, "payload too large\n", &.{}, false); return self.refuse(request, .payload_too_large, "payload too large\n", &.{}, false);
}; };
const reader = try request.readerExpectContinue(&.{}); const reader = try request.readerExpectContinue(&.{});
const got = reader.readSliceShort(&conn.query) catch return error.ReadFailed; const got = reader.readSliceShort(&conn.payload.query) catch return error.ReadFailed;
// A full buffer is either a message of exactly the DNS maximum // A full buffer is either a message of exactly the DNS maximum
// or a chunked body that keeps going; one probe byte decides. // or a chunked body that keeps going; one probe byte decides.
if (got == conn.query.len) { if (got == conn.payload.query.len) {
var probe: [1]u8 = undefined; var probe: [1]u8 = undefined;
const extra = reader.readSliceShort(&probe) catch return error.ReadFailed; const extra = reader.readSliceShort(&probe) catch return error.ReadFailed;
if (extra != 0) { if (extra != 0) {
return self.refuse(request, .payload_too_large, "payload too large\n", &.{}, false); return self.refuse(request, .payload_too_large, "payload too large\n", &.{}, false);
} }
} }
return self.answer(io, conn, request, conn.query[0..got]); return self.answer(io, conn, request, conn.payload.query[0..got]);
}, },
else => return self.refuse(request, .method_not_allowed, "method not allowed\n", &.{allow_header}, keep), else => return self.refuse(request, .method_not_allowed, "method not allowed\n", &.{allow_header}, keep),
} }
@@ -490,8 +385,8 @@ pub const DohServer = struct {
.tcp, .tcp,
address.NetAddress.fromIp(conn.peer), address.NetAddress.fromIp(conn.peer),
query, query,
&conn.reply, &conn.payload.reply,
&conn.scratch, &conn.payload.scratch,
); );
switch (outcome) { switch (outcome) {
.drop => return self.refuse(request, .bad_request, "bad request\n", &.{}, false), .drop => return self.refuse(request, .bad_request, "bad request\n", &.{}, false),
@@ -514,7 +409,7 @@ pub const DohServer = struct {
extra_headers: []const http.Header, extra_headers: []const http.Header,
keep_alive: bool, keep_alive: bool,
) error{ WriteFailed, HttpExpectationFailed }!Next { ) error{ WriteFailed, HttpExpectationFailed }!Next {
bump(&self.stats.bad_requests); listener.bump(&self.stats.bad_requests);
try request.respond(body, .{ try request.respond(body, .{
.status = status, .status = status,
.keep_alive = keep_alive, .keep_alive = keep_alive,
@@ -525,73 +420,8 @@ pub const DohServer = struct {
// `connection: close` either way, and the loop must agree. // `connection: close` either way, and the loop must agree.
return if (keep_alive and request.head.keep_alive) .keep_open else .close; return if (keep_alive and request.head.keep_alive) .keep_open else .close;
} }
fn claim(self: *DohServer, 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: *DohServer, 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: *DohServer, 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("doh connection shutdown failed: {t}", .{err});
};
}
}
}; };
/// `handshook` is set only after `accept` returned, so `serveConn` knows on
/// every race outcome whether `conn.tls` holds a context that must be closed.
fn handshake(
gpa: Allocator,
conn: *DohServer.Conn,
ctx: *tls_server.ServerContext,
io: std.Io,
handshook: *bool,
) anyerror!void {
try conn.tls.accept(gpa, ctx, io, &conn.stream, &conn.read_buf, &conn.write_buf);
handshook.* = true;
}
const ReceiveHeadResult = http.Server.ReceiveHeadError!http.Server.Request; const ReceiveHeadResult = http.Server.ReceiveHeadError!http.Server.Request;
/// The DoT out-param precedent (`readPrefix`'s `out_len`): `race` needs an /// The DoT out-param precedent (`readPrefix`'s `out_len`): `race` needs an
@@ -655,85 +485,6 @@ fn decodeDnsValue(value: []const u8, dest: []u8) error{Invalid}![]u8 {
return dest[0..len]; return dest[0..len];
} }
/// The whole claim rule, without the mutex, so it is testable without a backend.
fn decideClaim(conns: []const DohServer.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;
}
const Outcome = union(enum) {
op: anyerror!void,
expiry: std.Io.Cancelable!void,
};
const RaceResult = enum { ok, timed_out, failed, canceled };
/// Runs one connection operation against the budget and cancels the loser
/// (tcp_server's arrangement: no stream operation in 0.16.0 takes a timeout).
fn race(
io: std.Io,
budget: std.Io.Clock.Duration,
comptime f: anytype,
args: std.meta.ArgsTuple(@TypeOf(f)),
) RaceResult {
var outcomes: [2]Outcome = undefined;
var select: std.Io.Select(Outcome) = .init(io, &outcomes);
defer select.cancelDiscard();
select.concurrent(.op, f, args) catch |err| switch (err) {
error.ConcurrencyUnavailable => return .failed,
};
select.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return .failed,
};
return switch (select.await() catch return .canceled) {
.op => |result| if (result) |_| .ok else |err| switch (err) {
error.Canceled => .canceled,
else => .failed,
},
// A canceled sleep means this task is being torn down, not that the
// peer went idle.
.expiry => |result| if (result) |_| .timed_out else |_| .canceled,
};
}
fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void {
return budget.sleep(io);
}
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 (ruling 1, the web precedent): DoH failing to come up
/// must not stop nxdns answering plain DNS.
pub fn serve(
gpa: Allocator,
io: std.Io,
endpoint: model.TlsEndpoint,
h: *handler.Handler,
certs: *cert_store.CertStore,
) void {
const bind_address = net.IpAddress.parse(endpoint.bind, endpoint.port) catch {
log.warn("doh_server.bind '{s}' is not an IP address; DoH is disabled", .{endpoint.bind});
return;
};
var server: DohServer = DohServer.listen(gpa, io, bind_address, h, certs, .{}) catch |err| {
log.warn("doh listener cannot listen on {s}:{d}: {t}", .{ endpoint.bind, endpoint.port, err });
return;
};
defer server.deinit(gpa, io);
log.info("doh listener on {f}", .{server.boundAddress()});
server.serve(io);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// tests // tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -749,41 +500,6 @@ const local_tables_mod = @import("local_tables.zig");
const response = @import("../filter/response.zig"); const response = @import("../filter/response.zig");
const types = @import("../dns/types.zig"); const types = @import("../dns/types.zig");
fn testConns(count: usize) ![]DohServer.Conn {
const conns = try testing.allocator.alloc(DohServer.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 "framesBody sees framing in either header and none in content-length: 0" { test "framesBody sees framing in either header and none in content-length: 0" {
try testing.expect(framesBody(.chunked, null)); try testing.expect(framesBody(.chunked, null));
try testing.expect(framesBody(.none, 4)); try testing.expect(framesBody(.none, 4));
@@ -937,7 +653,7 @@ const Harness = struct {
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0); const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
hx.server = try DohServer.listen(testing.allocator, hio, listen_address, &hx.h, &hx.store, options); hx.server = try DohServer.listen(testing.allocator, hio, listen_address, &hx.h, &hx.store, options);
errdefer hx.server.deinit(testing.allocator, hio); errdefer hx.server.deinit(hio);
hx.group = .init; hx.group = .init;
try hx.group.concurrent(hio, DohServer.serve, .{ &hx.server, hio }); try hx.group.concurrent(hio, DohServer.serve, .{ &hx.server, hio });
@@ -945,7 +661,7 @@ const Harness = struct {
fn stop(hx: *Harness) void { fn stop(hx: *Harness) void {
const hio = hx.threaded.io(); const hio = hx.threaded.io();
hx.server.deinit(testing.allocator, hio); hx.server.deinit(hio);
hx.group.await(hio) catch |err| switch (err) { hx.group.await(hio) catch |err| switch (err) {
error.Canceled => unreachable, error.Canceled => unreachable,
}; };
@@ -977,7 +693,7 @@ fn bounded(io: std.Io, comptime f: anytype, args: std.meta.ArgsTuple(@TypeOf(f))
defer select.cancelDiscard(); defer select.cancelDiscard();
try select.concurrent(.work, f, args); try select.concurrent(.work, f, args);
try select.concurrent(.expiry, expire, .{ io, test_budget }); try select.concurrent(.expiry, listener.expire, .{ io, test_budget });
switch (try select.await()) { switch (try select.await()) {
.work => |result| return result, .work => |result| return result,
+88 -426
View File
@@ -1,12 +1,14 @@
//! The DoT listener (RFC 7858): the TCP/53 loop over a TLS stream. //! The DoT listener (RFC 7858): the TCP/53 loop over a TLS stream.
//! //!
//! This file mirrors `tcp_server.zig` — same slots, same claim rule, same //! The slot pool, the accept loop and the shutdown protocol are
//! shutdown paths, same idle race — with three differences: //! `listener.Core`'s (milestone-18 ruling 1), the same ones tcp_server uses.
//! What this file adds over TCP/53:
//! //!
//! - After the TCP accept, the certificate generation is pinned with //! - After the TCP accept, the certificate generation is pinned with
//! `CertStore.acquire` and the mbedTLS handshake runs under the same race //! `CertStore.acquire` and the mbedTLS handshake runs through
//! budget as every other per-connection operation, so a client that stalls //! `listener.handshakeStage` under the same race budget as every other
//! mid-handshake cannot pin a connection slot. //! per-connection operation, so a client that stalls mid-handshake cannot pin
//! a connection slot.
//! - The framed-message loop reads and writes through //! - The framed-message loop reads and writes through
//! `tls_server.ServerStream`, and closing the stream sends close_notify //! `tls_server.ServerStream`, and closing the stream sends close_notify
//! before the TCP close. A transport EOF without close_notify surfaces as a //! before the TCP close. A transport EOF without close_notify surfaces as a
@@ -23,20 +25,15 @@ const std = @import("std");
const address = @import("../platform/address.zig"); const address = @import("../platform/address.zig");
const cert_store = @import("cert_store.zig"); const cert_store = @import("cert_store.zig");
const handler = @import("handler.zig"); const handler = @import("handler.zig");
const listener = @import("listener.zig");
const tls_server = @import("../platform/tls_server.zig"); const tls_server = @import("../platform/tls_server.zig");
const transport = @import("../upstream/transport.zig"); const transport = @import("../upstream/transport.zig");
const log = std.log.scoped(.dot_server);
/// Plaintext staging for `ServerStream`: the framing bytes and the decrypted /// Plaintext staging for `ServerStream`: the framing bytes and the decrypted
/// record tail pass through here, while whole messages go straight to /// record tail pass through here, while whole messages go straight to
/// `Conn.query`/`Conn.reply`. /// `Payload.query`/`Payload.reply`.
const stream_buffer_len = 1024; const stream_buffer_len = 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 };
pub const Options = struct { pub const Options = struct {
max_connections: u16 = 64, max_connections: u16 = 64,
/// RFC 7766 §6.2.3 recommends a few seconds of idle tolerance, and the /// RFC 7766 §6.2.3 recommends a few seconds of idle tolerance, and the
@@ -44,16 +41,11 @@ pub const Options = struct {
idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake }, idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake },
}; };
/// What DoT counts on top of `listener.CoreStats`: handshakes that failed or
/// outran the idle budget. `connections` in the core counts TCP connections
/// accepted, whether or not the handshake succeeded.
pub const Stats = struct { pub const Stats = struct {
/// TCP connections accepted, whether or not the handshake succeeded.
connections: std.atomic.Value(u64) = .init(0),
/// Handshakes that failed or outran the idle budget.
tls_handshake_failures: std.atomic.Value(u64) = .init(0), tls_handshake_failures: std.atomic.Value(u64) = .init(0),
idle_timeouts: std.atomic.Value(u64) = .init(0),
connection_errors: 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),
}; };
/// The milestone-10 ruling 10 counters, the shape `metrics.counterGroup` /// The milestone-10 ruling 10 counters, the shape `metrics.counterGroup`
@@ -65,62 +57,19 @@ pub const StatsSnapshot = struct {
connection_errors: u64, connection_errors: u64,
}; };
/// Lifecycle of the accept loop. `serve` claims `.serving`, `deinit` publishes
/// `.closing`, and the two meet at `stopped` so no task touches a connection
/// slot after it is freed.
const State = enum(u32) { idle, serving, closing };
/// `.closing` exists so `deinit` never shuts down a descriptor that its own
/// task is about to close: the transition to `.closing` happens under the mutex
/// before the close, and `deinit` only touches `.active` slots.
const ConnState = enum { free, active, closing };
/// Why the accept loop stopped, which decides what happens to the connections
/// still in flight.
const Stop = enum {
/// `deinit` published `.closing`. It has already shut every live connection
/// down, so each one is unblocked and finishing on its own.
closing,
/// This task is being canceled. Nothing has touched the connections.
canceled,
};
/// What the accept loop does with a stream it has just accepted.
const Claim = union(enum) {
/// The stream owns `conns[index]`.
slot: usize,
/// Every slot is taken. The stream is closed and the loop continues.
at_capacity,
/// `deinit` has started. The stream is closed and the loop returns.
shutting_down,
};
pub const DotServer = struct { pub const DotServer = struct {
server: std.Io.net.Server, core: listener.Core(Config),
handler: *handler.Handler, handler: *handler.Handler,
certs: *cert_store.CertStore, certs: *cert_store.CertStore,
/// Kept for the per-connection ssl context `ServerStream.accept`
/// allocates and `close` frees.
gpa: std.mem.Allocator,
conns: []Conn,
mutex: std.Io.Mutex,
/// Guarded by `mutex`. `deinit` sets it in the same critical section that
/// shuts the active connections down, so a stream that arrives after that
/// scan can never claim a slot the scan will not visit again.
shutdown_begun: bool,
options: Options, options: Options,
stats: Stats, stats: Stats,
state: std.atomic.Value(State),
stopped: std.Io.Event,
/// One slot is ~137 KiB — the same two message ceilings as TCP/53 plus the /// One slot is ~137 KiB — the same two message ceilings as TCP/53 plus the
/// `ServerStream` bookkeeping — so the default 64 connections stay inside /// `ServerStream` bookkeeping — so the default 64 connections stay inside
/// the PLAN §18 budget. /// the PLAN §18 budget.
pub const Conn = struct { pub const Payload = struct {
query: [transport.max_message_len]u8, query: [transport.max_message_len]u8,
reply: [transport.max_message_len]u8, reply: [transport.max_message_len]u8,
read_buf: [stream_buffer_len]u8,
write_buf: [stream_buffer_len]u8,
/// The handler's per-query working memory. It belongs to the slot so /// The handler's per-query working memory. It belongs to the slot so
/// that answering a message allocates nothing, and a connection is /// that answering a message allocates nothing, and a connection is
/// answered serially, so one query uses it at a time. /// answered serially, so one query uses it at a time.
@@ -128,16 +77,20 @@ pub const DotServer = struct {
/// Pinned once its `accept` succeeds: mbedTLS holds a pointer to it, /// Pinned once its `accept` succeeds: mbedTLS holds a pointer to it,
/// and the slot never moves. /// and the slot never moves.
tls: tls_server.ServerStream, tls: tls_server.ServerStream,
stream: std.Io.net.Stream,
/// The client, read off the accepted socket once at claim time: every
/// message on this connection comes from the same peer, and the handler
/// needs it for rate limiting, groups and the query log.
peer: std.Io.net.IpAddress,
/// Guarded by `DotServer.mutex`.
state: ConnState,
}; };
pub const ListenError = std.Io.net.IpAddress.ListenError || error{OutOfMemory}; const Config = struct {
pub const Owner = DotServer;
pub const ConnPayload = Payload;
pub const serveConn = serveOne;
pub const read_buffer_len = stream_buffer_len;
pub const write_buffer_len = stream_buffer_len;
pub const log = std.log.scoped(.dot_server);
pub const name = "dot";
};
pub const Conn = listener.Core(Config).Conn;
pub const ListenError = listener.Core(Config).ListenError;
pub fn listen( pub fn listen(
gpa: std.mem.Allocator, gpa: std.mem.Allocator,
@@ -147,148 +100,47 @@ pub const DotServer = struct {
certs: *cert_store.CertStore, certs: *cert_store.CertStore,
options: Options, options: Options,
) ListenError!DotServer { ) ListenError!DotServer {
std.debug.assert(options.max_connections > 0);
const conns = try gpa.alloc(Conn, options.max_connections);
errdefer gpa.free(conns);
for (conns) |*conn| conn.state = .free;
const local = listen_address;
const server = try local.listen(io, .{ .reuse_address = true });
return .{ return .{
.server = server, .core = try listener.Core(Config).listen(gpa, io, listen_address, options.max_connections),
.handler = h, .handler = h,
.certs = certs, .certs = certs,
.gpa = gpa,
.conns = conns,
.mutex = .init,
.shutdown_begun = false,
.options = options, .options = options,
.stats = .{}, .stats = .{},
.state = .init(.idle),
.stopped = .unset,
}; };
} }
/// The kernel-assigned address. A port of 0 in `listen` resolves here. /// The kernel-assigned address. A port of 0 in `listen` resolves here.
pub fn boundAddress(self: *const DotServer) std.Io.net.IpAddress { pub fn boundAddress(self: *const DotServer) std.Io.net.IpAddress {
return self.server.socket.address; return self.core.boundAddress();
} }
/// Accept loop. Returns when the task is canceled or `deinit` stops it. /// Accept loop. Returns when the task is canceled or `deinit` stops it.
pub fn serve(self: *DotServer, io: std.Io) void { pub fn serve(self: *DotServer, io: std.Io) void {
if (self.state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return; self.core.serve(io);
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 already unblocked and ending on its
// own. Awaiting them means a half-written reply still goes out
// whole, and the wait is bounded by the shutdown, not the client.
.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: `deinit` cannot run
// until this task returns, and RFC 7766 lets a client hold a
// connection open forever by asking again inside the idle budget.
// Draining here would therefore let one client stall the whole
// process's shutdown for as long as it likes. `cancel` requests
// cancellation and joins, so the slots are still quiet — and the
// buffers still unreferenced — by the time `serve` returns; the
// price is the one reply that was mid-write.
.canceled => group.cancel(io),
}
self.stopped.set(io);
} }
pub fn deinit(self: *DotServer, io: std.Io) void { pub fn deinit(self: *DotServer, io: std.Io) void {
const was_serving = self.state.swap(.closing, .acq_rel) == .serving; self.core.deinit(io);
// Shutting the listening socket down is the documented way to unblock a
// pending `accept`: it fails with `error.SocketNotListening`.
const listener: std.Io.net.Stream = .{ .socket = self.server.socket };
listener.shutdown(io, .both) catch |err| {
log.debug("dot listener shutdown failed: {t}", .{err});
};
// A live connection is blocked in a read that only the idle budget
// would end, which is seconds away. Shutting each one down bounds this,
// and the same critical section closes the door on new connections.
self.beginShutdown(io);
if (was_serving) self.stopped.waitUncancelable(io);
self.server.deinit(io);
self.gpa.free(self.conns);
self.* = undefined; self.* = undefined;
} }
pub fn snapshotStats(self: *const DotServer) StatsSnapshot { pub fn snapshotStats(self: *const DotServer) StatsSnapshot {
const core = &self.core.stats;
return .{ return .{
.connections = self.stats.connections.load(.monotonic), .connections = core.connections.load(.monotonic),
.tls_handshake_failures = self.stats.tls_handshake_failures.load(.monotonic), .tls_handshake_failures = self.stats.tls_handshake_failures.load(.monotonic),
.idle_timeouts = self.stats.idle_timeouts.load(.monotonic), .idle_timeouts = core.idle_timeouts.load(.monotonic),
.connection_errors = self.stats.connection_errors.load(.monotonic), .connection_errors = core.connection_errors.load(.monotonic),
}; };
} }
fn acceptLoop(self: *DotServer, io: std.Io, group: *std.Io.Group) Stop { /// One connection: pin, handshake, serve, close_notify, release — the
while (self.state.load(.acquire) == .serving) { /// ordering `listener.handshakeStage` documents. The core closes the TCP
const stream = self.server.accept(io) catch |err| switch (err) { /// stream after this returns.
error.Canceled => return .canceled, fn serveOne(self: *DotServer, io: std.Io, index: usize) void {
// `deinit` shuts the listening socket down to unblock exactly const conn = &self.core.conns[index];
// this call, so it is the shutdown path arriving early. const stats = &self.core.stats;
error.SocketNotListening => return .closing, const gpa = self.core.gpa;
else => {
bump(&self.stats.accept_errors);
log.debug("dot accept failed: {t}", .{err});
retry_delay.sleep(io) catch return .canceled;
continue;
},
};
const index = switch (self.claim(io, stream)) {
.slot => |index| index,
// Refusing now is honest; a queue would only hide the overload.
.at_capacity => {
bump(&self.stats.rejected_at_capacity);
stream.close(io);
continue;
},
// `deinit` will not see this stream in any slot, so serving it
// would hold `deinit` for the whole idle budget.
.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.connections);
}
// The loop condition failed, which only `deinit` can cause.
return .closing;
}
fn serveConn(self: *DotServer, io: std.Io, index: usize) void {
defer self.finish(io, index);
const conn = &self.conns[index];
const budget = self.options.idle_timeout; const budget = self.options.idle_timeout;
// Pins the certificate generation for the whole connection: a reload // Pins the certificate generation for the whole connection: a reload
@@ -297,46 +149,38 @@ pub const DotServer = struct {
const entry = self.certs.acquire(io); const entry = self.certs.acquire(io);
defer self.certs.release(io, entry); defer self.certs.release(io, entry);
var handshook = false; const stage: Handshake = .{ .conn = conn, .gpa = gpa, .ctx = &entry.ctx, .io = io };
switch (race(io, budget, handshake, .{ conn, self.gpa, &entry.ctx, io, &handshook })) { switch (listener.handshakeStage(io, budget, stage)) {
.ok => {}, .ok => {},
// The select can report the expiry or the cancellation after the .canceled => return,
// handshake has in fact succeeded. The flag is written before the
// race joins its tasks, so a TLS context that exists is closed on
// every path, exactly once.
.canceled => {
if (handshook) conn.tls.close(self.gpa);
return;
},
// A stalled handshake is refused like a broken one: it must not // A stalled handshake is refused like a broken one: it must not
// pin a connection slot for longer than the idle budget. // pin a connection slot for longer than the idle budget.
.timed_out, .failed => { .timed_out, .failed => {
if (handshook) conn.tls.close(self.gpa); listener.bump(&self.stats.tls_handshake_failures);
bump(&self.stats.tls_handshake_failures);
return; return;
}, },
} }
// Sends close_notify and frees the ssl context; `finish` closes the // Sends close_notify and frees the ssl context; the core closes the
// TCP stream afterwards. // TCP stream afterwards.
defer conn.tls.close(self.gpa); defer conn.payload.tls.close(gpa);
const reader = conn.tls.reader(); const reader = conn.payload.tls.reader();
const writer = conn.tls.writer(); const writer = conn.payload.tls.writer();
while (true) { while (true) {
var prefix: [transport.prefix_len]u8 = undefined; var prefix: [transport.prefix_len]u8 = undefined;
var got: usize = 0; var got: usize = 0;
switch (race(io, budget, readPrefix, .{ reader, &prefix, &got })) { switch (listener.race(io, budget, listener.readPrefix, .{ reader, &prefix, &got })) {
.ok => {}, .ok => {},
.timed_out => { .timed_out => {
bump(&self.stats.idle_timeouts); listener.bump(&stats.idle_timeouts);
return; return;
}, },
.canceled => return, .canceled => return,
// A transport EOF without close_notify lands here too: the // A transport EOF without close_notify lands here too: the
// stream reads it as a truncation, never as a clean end. // stream reads it as a truncation, never as a clean end.
.failed => { .failed => {
bump(&self.stats.connection_errors); listener.bump(&stats.connection_errors);
return; return;
}, },
} }
@@ -345,7 +189,7 @@ pub const DotServer = struct {
// asking, which is the normal end of a connection, not a failure. // asking, which is the normal end of a connection, not a failure.
if (got == 0) return; if (got == 0) return;
if (got != transport.prefix_len) { if (got != transport.prefix_len) {
bump(&self.stats.connection_errors); listener.bump(&stats.connection_errors);
return; return;
} }
@@ -353,16 +197,16 @@ pub const DotServer = struct {
// the prefix is a u16 so it can never exceed `max_message_len`. // the prefix is a u16 so it can never exceed `max_message_len`.
const len = transport.parsePrefix(prefix); const len = transport.parsePrefix(prefix);
if (len == 0) { if (len == 0) {
bump(&self.stats.connection_errors); listener.bump(&stats.connection_errors);
return; return;
} }
switch (race(io, budget, readBody, .{ reader, conn.query[0..len] })) { switch (listener.race(io, budget, listener.readBody, .{ reader, conn.payload.query[0..len] })) {
.ok => {}, .ok => {},
.canceled => return, .canceled => return,
// A half-sent message is a broken peer, not an idle one. // A half-sent message is a broken peer, not an idle one.
.timed_out, .failed => { .timed_out, .failed => {
bump(&self.stats.connection_errors); listener.bump(&stats.connection_errors);
return; return;
}, },
} }
@@ -371,9 +215,9 @@ pub const DotServer = struct {
io, io,
.tcp, .tcp,
address.NetAddress.fromIp(conn.peer), address.NetAddress.fromIp(conn.peer),
conn.query[0..len], conn.payload.query[0..len],
&conn.reply, &conn.payload.reply,
&conn.scratch, &conn.payload.scratch,
); );
const bytes = switch (outcome) { const bytes = switch (outcome) {
// There is no framing for "no answer", so the connection ends. // There is no framing for "no answer", so the connection ends.
@@ -382,162 +226,43 @@ pub const DotServer = struct {
}; };
const out = transport.framePrefix(@intCast(bytes.len)); const out = transport.framePrefix(@intCast(bytes.len));
switch (race(io, budget, writeReply, .{ writer, &out, bytes })) { switch (listener.race(io, budget, listener.writeReply, .{ writer, &out, bytes })) {
.ok => {}, .ok => {},
.canceled => return, .canceled => return,
.timed_out, .failed => { .timed_out, .failed => {
bump(&self.stats.connection_errors); listener.bump(&stats.connection_errors);
return; return;
}, },
} }
} }
} }
fn claim(self: *DotServer, io: std.Io, stream: std.Io.net.Stream) Claim { /// The `listener.handshakeStage` stage: everything one mbedTLS handshake
// Uncancelable: this section takes no Io and never blocks on a peer, so /// needs, plus the close that undoes it.
// it cannot deadlock, and losing the lock mid-update would leak a slot. const Handshake = struct {
self.mutex.lockUncancelable(io); conn: *Conn,
defer self.mutex.unlock(io); gpa: std.mem.Allocator,
ctx: *tls_server.ServerContext,
io: std.Io,
const outcome = decideClaim(self.conns, self.shutdown_begun); pub fn accept(self: Handshake) anyerror!void {
switch (outcome) { const conn = self.conn;
.slot => |index| { try conn.payload.tls.accept(
self.conns[index].stream = stream; self.gpa,
self.conns[index].peer = stream.socket.address; self.ctx,
self.conns[index].state = .active; self.io,
}, &conn.stream,
.at_capacity, .shutting_down => {}, &conn.read_buf,
&conn.write_buf,
);
} }
return outcome;
}
fn finish(self: *DotServer, io: std.Io, index: usize) void { pub fn close(self: Handshake) void {
const conn = &self.conns[index]; self.conn.payload.tls.close(self.gpa);
self.mutex.lockUncancelable(io);
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.state = .free;
self.mutex.unlock(io);
}
/// Closes the door on new connections and unblocks the live ones. Both
/// happen under one hold of the mutex: a `claim` that runs before this
/// leaves an `.active` slot the loop below shuts down, and a `claim` that
/// runs after it reads `shutdown_begun` and takes no slot at all.
fn beginShutdown(self: *DotServer, io: std.Io) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.shutdown_begun = true;
for (self.conns) |*conn| {
if (conn.state != .active) continue;
conn.stream.shutdown(io, .both) catch |err| {
log.debug("dot connection shutdown failed: {t}", .{err});
};
} }
} };
}; };
/// The capacity rule, without the mutex, so it is testable without a backend.
fn firstFree(conns: []const DotServer.Conn) ?usize {
for (conns, 0..) |*conn, index| {
if (conn.state == .free) return index;
}
return null;
}
/// The whole claim rule, without the mutex. Shutdown outranks capacity: a free
/// slot is still refused once `deinit` has passed the connections.
fn decideClaim(conns: []const DotServer.Conn, shutdown_begun: bool) Claim {
if (shutdown_begun) return .shutting_down;
const index = firstFree(conns) orelse return .at_capacity;
return .{ .slot = index };
}
const Outcome = union(enum) {
op: anyerror!void,
expiry: std.Io.Cancelable!void,
};
const Result = enum { ok, timed_out, failed, canceled };
/// Runs one connection operation against the idle budget and cancels the loser.
fn race(
io: std.Io,
budget: std.Io.Clock.Duration,
comptime f: anytype,
args: std.meta.ArgsTuple(@TypeOf(f)),
) Result {
var outcomes: [2]Outcome = undefined;
var select: std.Io.Select(Outcome) = .init(io, &outcomes);
defer select.cancelDiscard();
select.concurrent(.op, f, args) catch |err| switch (err) {
error.ConcurrencyUnavailable => return .failed,
};
select.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return .failed,
};
return switch (select.await() catch return .canceled) {
.op => |result| if (result) |_| .ok else |err| switch (err) {
error.Canceled => .canceled,
else => .failed,
},
// A canceled sleep means this task is being torn down, not that the
// client went idle.
.expiry => |result| if (result) |_| .timed_out else |_| .canceled,
};
}
fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void {
return budget.sleep(io);
}
/// `handshook` is set only after `accept` returned, so `serveConn` knows on
/// the losing race paths whether a TLS context exists that must be closed.
fn handshake(
conn: *DotServer.Conn,
gpa: std.mem.Allocator,
ctx: *tls_server.ServerContext,
io: std.Io,
handshook: *bool,
) anyerror!void {
try conn.tls.accept(gpa, ctx, io, &conn.stream, &conn.read_buf, &conn.write_buf);
handshook.* = true;
}
/// `readSliceShort` rather than `readSliceAll`: a zero-length read is a client
/// that sent close_notify between messages, and only a partial prefix is an
/// error.
fn readPrefix(reader: *std.Io.Reader, buf: *[transport.prefix_len]u8, out_len: *usize) anyerror!void {
out_len.* = try reader.readSliceShort(buf);
}
fn readBody(reader: *std.Io.Reader, buf: []u8) anyerror!void {
return reader.readSliceAll(buf);
}
fn writeReply(writer: *std.Io.Writer, prefix: *const [transport.prefix_len]u8, bytes: []const u8) anyerror!void {
try writer.writeAll(prefix);
try writer.writeAll(bytes);
try writer.flush();
}
fn bump(counter: *std.atomic.Value(u64)) void {
_ = counter.fetchAdd(1, .monotonic);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// tests // tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -550,78 +275,15 @@ const packet = @import("../dns/packet.zig");
const response = @import("../filter/response.zig"); const response = @import("../filter/response.zig");
const types = @import("../dns/types.zig"); const types = @import("../dns/types.zig");
fn testConns(count: usize) ![]DotServer.Conn {
const conns = try testing.allocator.alloc(DotServer.Conn, count);
for (conns) |*conn| conn.state = .free;
return conns;
}
test "the connection pool hands out every slot once" {
const conns = try testConns(3);
defer testing.allocator.free(conns);
for (0..conns.len) |expected| {
const index = firstFree(conns) orelse return error.TestUnexpectedResult;
try testing.expectEqual(expected, index);
conns[index].state = .active;
}
}
test "a full connection pool refuses instead of growing" {
const conns = try testConns(2);
defer testing.allocator.free(conns);
for (conns) |*conn| conn.state = .active;
try testing.expectEqual(@as(?usize, null), firstFree(conns));
}
test "a closing slot is not reused until it is free" {
const conns = try testConns(2);
defer testing.allocator.free(conns);
conns[0].state = .active;
conns[1].state = .closing;
try testing.expectEqual(@as(?usize, null), firstFree(conns));
conns[1].state = .free;
try testing.expectEqual(@as(?usize, 1), firstFree(conns));
}
test "a claim takes the first free slot before shutdown" {
const conns = try testConns(2);
defer testing.allocator.free(conns);
conns[0].state = .active;
try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot);
}
test "a claim after shutdown is refused even with a free slot" {
const conns = try testConns(2);
defer testing.allocator.free(conns);
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
// The refusal must not consume the slot: `deinit` frees it, nothing else.
try testing.expectEqual(@as(?usize, 0), firstFree(conns));
}
test "shutdown outranks capacity" {
const conns = try testConns(1);
defer testing.allocator.free(conns);
conns[0].state = .active;
try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false)));
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
}
test "snapshotStats reports the ruling-10 counters" { test "snapshotStats reports the ruling-10 counters" {
var server: DotServer = undefined; var server: DotServer = undefined;
server.core.stats = .{};
server.stats = .{}; server.stats = .{};
bump(&server.stats.connections); listener.bump(&server.core.stats.connections);
bump(&server.stats.connections); listener.bump(&server.core.stats.connections);
bump(&server.stats.tls_handshake_failures); listener.bump(&server.stats.tls_handshake_failures);
bump(&server.stats.connection_errors); listener.bump(&server.core.stats.connection_errors);
const snapshot = server.snapshotStats(); const snapshot = server.snapshotStats();
try testing.expectEqual(@as(u64, 2), snapshot.connections); try testing.expectEqual(@as(u64, 2), snapshot.connections);
@@ -743,7 +405,7 @@ fn bounded(io: std.Io, comptime f: anytype, args: std.meta.ArgsTuple(@TypeOf(f))
defer select.cancelDiscard(); defer select.cancelDiscard();
try select.concurrent(.work, f, args); try select.concurrent(.work, f, args);
try select.concurrent(.expiry, expire, .{ io, test_budget }); try select.concurrent(.expiry, listener.expire, .{ io, test_budget });
switch (try select.await()) { switch (try select.await()) {
.work => |result| return result, .work => |result| return result,
@@ -953,7 +615,7 @@ test "dot: a transport EOF without close_notify is a connection error, not a cra
try group.concurrent(io, DotServer.serve, .{ &server, io }); try group.concurrent(io, DotServer.serve, .{ &server, io });
try bounded(io, dotDropWithoutCloseNotify, .{ io, server_address }); try bounded(io, dotDropWithoutCloseNotify, .{ io, server_address });
try waitForCounter(io, &server.stats.connection_errors, 1); try waitForCounter(io, &server.core.stats.connection_errors, 1);
const stats = server.snapshotStats(); const stats = server.snapshotStats();
try testing.expectEqual(@as(u64, 1), stats.connections); try testing.expectEqual(@as(u64, 1), stats.connections);
+568
View File
@@ -0,0 +1,568 @@
//! The listener core the four stream listeners share.
//!
//! `tcp_server.zig`, `dot_server.zig`, `doh_server.zig` and `web/server.zig`
//! are the same machine wearing four transports: a fixed pre-allocated slot
//! pool, a claim rule where shutdown outranks capacity, an accept loop with one
//! error mapping, a mutex-ordered close dance, and — for everything that waits
//! on a peer — one select race against a budget. Milestone 18 ruling 1 puts
//! that machine here once, so a fix to it lands once.
//!
//! What stays outside: the per-connection serve function, the connection
//! payload (buffers, TLS context, arenas), and the TLS lifecycle. A TLS
//! listener's certificate pin, handshake, close_notify and release form one
//! ordered sequence that the Core has no business owning; what it does own is
//! `handshakeStage`, the exactly-once `handshook` flag whose absence was a real
//! leak in doh_server before the milestone-10 review hand-ported the fix.
//!
//! Shutdown is the one part worth reading twice. `deinit` publishes `.closing`,
//! shuts the listening socket down (which unblocks `accept` with
//! `error.SocketNotListening`) and shuts every `.active` connection down in the
//! same critical section that closes the door on new ones; `serve` then drains
//! its connection group so a half-written reply still goes out whole. A
//! *canceled* `serve` cannot drain, because a keep-alive peer has no deadline
//! of its own and one chatty client would stall the whole process's shutdown;
//! it cancels the group instead, at the cost of the one reply mid-write.
//! Either way `serve` returns only once no task can still touch a slot.
const std = @import("std");
const net = std.Io.net;
const Allocator = std.mem.Allocator;
const transport = @import("../upstream/transport.zig");
/// Lifecycle of the accept loop. `serve` claims `.serving`, `deinit` publishes
/// `.closing`, and the two meet at `stopped` so no task touches a connection
/// slot after it is freed.
pub const State = enum(u32) { idle, serving, closing };
/// `.closing` exists so `deinit` never shuts down a descriptor that its own
/// task is about to close: the transition to `.closing` happens under the mutex
/// before the close, and `deinit` only touches `.active` slots.
pub const ConnState = enum { free, active, closing };
/// Why the accept loop stopped, which decides what happens to the connections
/// still in flight.
pub const Stop = enum {
/// `deinit` published `.closing`. It has already shut every live connection
/// down, so each one is unblocked and finishing on its own.
closing,
/// This task is being canceled. Nothing has touched the connections.
canceled,
};
/// What the accept loop does with a stream it has just accepted.
pub const Claim = union(enum) {
/// The stream owns `conns[index]`.
slot: usize,
/// Every slot is taken. The stream is refused and the loop continues.
at_capacity,
/// `deinit` has started. The stream is closed and the loop returns.
shutting_down,
};
/// How long the accept loop waits after an unexpected accept failure, so a
/// persistent one cannot turn the loop into a spin.
pub const retry_delay: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake };
pub fn bump(counter: *std.atomic.Value(u64)) void {
_ = counter.fetchAdd(1, .monotonic);
}
/// The counters every listener keeps. Listener-specific ones —
/// `tls_handshake_failures`, `bad_requests`, `requests` — live beside this in
/// the owning listener, and each listener's exported `Snapshot` stays a flat
/// hand-written struct so `/metrics` output does not depend on this layout.
///
/// `idle_timeouts` is bumped by the three DNS listeners; the web listener has
/// no idle race (its port is LAN-facing and the cancel path bounds shutdown),
/// so its copy stays zero and it exports no family at all.
pub const CoreStats = struct {
connections: 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),
idle_timeouts: std.atomic.Value(u64) = .init(0),
};
// ---------------------------------------------------------------------------
// the race harness
// ---------------------------------------------------------------------------
pub const Outcome = union(enum) {
op: anyerror!void,
expiry: std.Io.Cancelable!void,
};
pub const Result = enum { ok, timed_out, failed, canceled };
/// Runs one connection operation against a budget and cancels the loser. No
/// stream read or write in 0.16.0 accepts a timeout, so every wait on a peer
/// that owes nxdns bytes goes through here.
pub fn race(
io: std.Io,
budget: std.Io.Clock.Duration,
comptime f: anytype,
args: std.meta.ArgsTuple(@TypeOf(f)),
) Result {
var outcomes: [2]Outcome = undefined;
var select: std.Io.Select(Outcome) = .init(io, &outcomes);
defer select.cancelDiscard();
select.concurrent(.op, f, args) catch |err| switch (err) {
error.ConcurrencyUnavailable => return .failed,
};
select.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return .failed,
};
return switch (select.await() catch return .canceled) {
.op => |result| if (result) |_| .ok else |err| switch (err) {
error.Canceled => .canceled,
else => .failed,
},
// A canceled sleep means this task is being torn down, not that the
// peer went idle.
.expiry => |result| if (result) |_| .timed_out else |_| .canceled,
};
}
pub fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void {
return budget.sleep(io);
}
// ---------------------------------------------------------------------------
// the TLS handshake stage
// ---------------------------------------------------------------------------
/// Runs a TLS handshake under `budget` and owns the exactly-once cleanup of the
/// context it may have created.
///
/// `stage` is anything with `accept(self) anyerror!void` and `close(self) void`.
/// The select can report the expiry or the cancellation *after* `accept` has in
/// fact succeeded, so a context that exists must be closed on every losing path
/// and on no other: the flag below is written before the race joins its tasks,
/// which is what makes "exactly once" true. Getting this wrong leaks one
/// mbedTLS ssl context per stalled handshake, which is what doh_server did
/// until the milestone-10 review hand-ported dot_server's fix — the duplication
/// this helper exists to end.
///
/// The caller's required ordering, which stays in the caller because the
/// certificate pin and the plaintext close are the listener's own business:
///
/// 1. pin the certificate generation (`CertStore.acquire`, released on exit),
/// 2. call `handshakeStage`,
/// 3. on `.ok` only: serve the connection,
/// 4. close the TLS stream (close_notify + free the context),
/// 5. release the pin, then let the slot's `finish` close the TCP stream.
///
/// On any result other than `.ok` this function has already done step 4 for the
/// caller, and the caller must not repeat it.
pub fn handshakeStage(io: std.Io, budget: std.Io.Clock.Duration, stage: anytype) Result {
const Stage = @TypeOf(stage);
const run = struct {
fn accept(s: Stage, handshook: *bool) anyerror!void {
try s.accept();
handshook.* = true;
}
}.accept;
var handshook = false;
const result = race(io, budget, run, .{ stage, &handshook });
if (result != .ok and handshook) stage.close();
return result;
}
// ---------------------------------------------------------------------------
// the framed-message helpers (RFC 1035 §4.2.2; tcp and dot)
// ---------------------------------------------------------------------------
/// `readSliceShort` rather than `readSliceAll`: a zero-length read is a client
/// that closed cleanly between messages, and only a partial prefix is an error.
pub fn readPrefix(reader: *std.Io.Reader, buf: *[transport.prefix_len]u8, out_len: *usize) anyerror!void {
out_len.* = try reader.readSliceShort(buf);
}
pub fn readBody(reader: *std.Io.Reader, buf: []u8) anyerror!void {
return reader.readSliceAll(buf);
}
pub fn writeReply(writer: *std.Io.Writer, prefix: *const [transport.prefix_len]u8, bytes: []const u8) anyerror!void {
try writer.writeAll(prefix);
try writer.writeAll(bytes);
try writer.flush();
}
// ---------------------------------------------------------------------------
// the claim rule
// ---------------------------------------------------------------------------
/// The capacity rule, without the mutex, so it is testable without a backend.
/// `conns` is any slice whose element has a `state: ConnState`.
pub fn firstFree(conns: anytype) ?usize {
for (conns, 0..) |*conn, index| {
if (conn.state == .free) return index;
}
return null;
}
/// The whole claim rule, without the mutex. Shutdown outranks capacity: a free
/// slot is still refused once `deinit` has passed the connections.
pub fn decideClaim(conns: anytype, shutdown_begun: bool) Claim {
if (shutdown_begun) return .shutting_down;
const index = firstFree(conns) orelse return .at_capacity;
return .{ .slot = index };
}
// ---------------------------------------------------------------------------
// the core
// ---------------------------------------------------------------------------
/// The slot pool, the accept loop and the shutdown protocol, parameterized over
/// the four things that genuinely differ between listeners.
///
/// `Cfg` declares:
///
/// - `Owner: type` — the listener struct that embeds this core in a field
/// named `core`. The accept loop recovers it with `@fieldParentPtr`, so an
/// owner must not move after `listen`.
/// - `ConnPayload: type` — the rest of one slot: message buffers, a TLS
/// context, a per-request arena. Never touched here.
/// - `serveConn: fn (*Owner, std.Io, usize) void` — one whole connection. The
/// core spawns it, and closes the slot when it returns.
/// - `read_buffer_len` / `write_buffer_len` — the stream staging buffers, which
/// every listener has and sizes differently.
/// - `log` — the owner's `std.log` scope, and `name` — the two or three letters
/// its messages already start with, so the log text does not change.
///
/// Optional, absent for most listeners:
///
/// - `refuse: fn (std.Io, net.Stream) void` — what an over-capacity accept does
/// with the stream. The default closes it, which is the only honest answer a
/// DNS listener can give; the web listener answers 503 first.
/// - `initPayload` / `deinitPayload` — for a payload that owns memory (the web
/// listener's per-connection arena). `initPayload` runs inside `listen`,
/// `deinitPayload` inside `deinit` after every connection task has joined.
pub fn Core(comptime Cfg: type) type {
return struct {
const Self = @This();
gpa: Allocator,
listener: net.Server,
conns: []Conn,
mutex: std.Io.Mutex,
/// Guarded by `mutex`. `deinit` sets it in the same critical section
/// that shuts the active connections down, so a stream that arrives
/// after that scan can never claim a slot the scan will not visit again.
shutdown_begun: bool,
stats: CoreStats,
run_state: std.atomic.Value(State),
stopped: std.Io.Event,
pub const Conn = struct {
/// The stream staging buffers. For the plaintext listeners these
/// feed the socket reader and writer; for the TLS ones they are the
/// `ServerStream` plaintext buffers.
read_buf: [Cfg.read_buffer_len]u8,
write_buf: [Cfg.write_buffer_len]u8,
payload: Cfg.ConnPayload,
stream: net.Stream,
/// The client, read off the accepted socket once at claim time:
/// every message on this connection comes from the same peer.
peer: net.IpAddress,
/// Guarded by `Core.mutex`.
state: ConnState,
};
pub const ListenError = net.IpAddress.ListenError || error{OutOfMemory};
pub fn listen(
gpa: Allocator,
io: std.Io,
listen_address: net.IpAddress,
max_connections: u16,
) ListenError!Self {
std.debug.assert(max_connections > 0);
const conns = try gpa.alloc(Conn, max_connections);
errdefer gpa.free(conns);
for (conns) |*conn| {
conn.state = .free;
if (@hasDecl(Cfg, "initPayload")) Cfg.initPayload(&conn.payload, gpa);
}
const listener = try listen_address.listen(io, .{ .reuse_address = true });
return .{
.gpa = gpa,
.listener = listener,
.conns = conns,
.mutex = .init,
.shutdown_begun = false,
.stats = .{},
.run_state = .init(.idle),
.stopped = .unset,
};
}
pub fn deinit(self: *Self, 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 stream: net.Stream = .{ .socket = self.listener.socket };
stream.shutdown(io, .both) catch |err| {
Cfg.log.debug(Cfg.name ++ " listener shutdown failed: {t}", .{err});
};
// A live connection is blocked in a read that only its own budget
// would end, which is seconds away. Shutting each one down bounds
// this, and the same critical section closes the door on new ones.
self.beginShutdown(io);
if (was_serving) self.stopped.waitUncancelable(io);
self.listener.deinit(io);
if (@hasDecl(Cfg, "deinitPayload")) {
for (self.conns) |*conn| Cfg.deinitPayload(&conn.payload);
}
self.gpa.free(self.conns);
self.* = undefined;
}
/// The kernel-assigned address. A port of 0 in `listen` resolves here.
pub fn boundAddress(self: *const Self) net.IpAddress {
return self.listener.socket.address;
}
/// Accept loop. Returns when the task is canceled or `deinit` stops it.
pub fn serve(self: *Self, 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 already unblocked and ending on
// its own. Awaiting them means a half-written reply still goes
// out whole, and the wait is bounded by the shutdown, not the
// peer.
.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: `deinit` cannot run
// until this task returns, and a peer may hold a connection
// open indefinitely, so draining would let one client stall the
// whole process's shutdown. `cancel` requests cancellation and
// joins, so the slots are quiet — and the buffers still
// unreferenced — by the time `serve` returns; the price is the
// one reply that was mid-write.
.canceled => group.cancel(io),
}
self.stopped.set(io);
}
/// The listener that embeds this core. Valid because the core is a
/// field of it and neither may move after `listen`.
pub fn owner(self: *Self) *Cfg.Owner {
return @alignCast(@fieldParentPtr("core", self));
}
fn acceptLoop(self: *Self, 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,
// `deinit` shuts the listening socket down to unblock
// exactly this call, so it is the shutdown path arriving
// early.
error.SocketNotListening => return .closing,
else => {
bump(&self.stats.accept_errors);
Cfg.log.debug(Cfg.name ++ " accept failed: {t}", .{err});
retry_delay.sleep(io) catch return .canceled;
continue;
},
};
const index = switch (self.claim(io, stream)) {
.slot => |index| index,
// Refusing now is honest; a queue would only hide the
// overload.
.at_capacity => {
bump(&self.stats.rejected_at_capacity);
if (@hasDecl(Cfg, "refuse")) Cfg.refuse(io, stream) else stream.close(io);
continue;
},
// `deinit` will not see this stream in any slot, so serving
// it would hold `deinit` for a whole idle budget.
.shutting_down => {
bump(&self.stats.rejected_at_shutdown);
stream.close(io);
return .closing;
},
};
group.concurrent(io, runConn, .{ self, io, index }) catch |err| switch (err) {
error.ConcurrencyUnavailable => {
bump(&self.stats.rejected_at_capacity);
self.finish(io, index);
continue;
},
};
bump(&self.stats.connections);
}
// The loop condition failed, which only `deinit` can cause.
return .closing;
}
/// One connection task: the listener's own logic, then the slot close.
/// Every early return inside `Cfg.serveConn` — and its own defers, such
/// as a TLS close_notify — runs before the TCP stream is closed here.
fn runConn(self: *Self, io: std.Io, index: usize) void {
defer self.finish(io, index);
Cfg.serveConn(self.owner(), io, index);
}
fn claim(self: *Self, io: std.Io, stream: net.Stream) Claim {
// Uncancelable: this section takes no Io and never blocks on a
// peer, so it cannot deadlock, and losing the lock mid-update would
// leak a slot.
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].state = .active;
},
.at_capacity, .shutting_down => {},
}
return outcome;
}
fn finish(self: *Self, io: std.Io, index: usize) void {
const conn = &self.conns[index];
self.mutex.lockUncancelable(io);
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.state = .free;
self.mutex.unlock(io);
}
/// Closes the door on new connections and unblocks the live ones. Both
/// happen under one hold of the mutex: a `claim` that runs before this
/// leaves an `.active` slot the loop below shuts down, and a `claim`
/// that runs after it reads `shutdown_begun` and takes no slot at all.
fn beginShutdown(self: *Self, io: std.Io) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.shutdown_begun = true;
for (self.conns) |*conn| {
if (conn.state != .active) continue;
conn.stream.shutdown(io, .both) catch |err| {
Cfg.log.debug(Cfg.name ++ " connection shutdown failed: {t}", .{err});
};
}
}
};
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
//
// The claim rule is the whole of the shared state machine that can be tested
// without a backend, and it reads nothing but `state`, so these tests use a
// bare slot instead of instantiating a `Core`. They replace six copies that
// lived in tcp_server.zig and dot_server.zig and three more between
// doh_server.zig and web/server.zig.
const testing = std.testing;
const TestSlot = struct { state: ConnState };
fn testConns(count: usize) ![]TestSlot {
const conns = try testing.allocator.alloc(TestSlot, count);
for (conns) |*conn| conn.state = .free;
return conns;
}
test "the connection pool hands out every slot once" {
const conns = try testConns(3);
defer testing.allocator.free(conns);
for (0..conns.len) |expected| {
const index = firstFree(conns) orelse return error.TestUnexpectedResult;
try testing.expectEqual(expected, index);
conns[index].state = .active;
}
}
test "a full connection pool refuses instead of growing" {
const conns = try testConns(2);
defer testing.allocator.free(conns);
for (conns) |*conn| conn.state = .active;
try testing.expectEqual(@as(?usize, null), firstFree(conns));
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(2);
defer testing.allocator.free(conns);
conns[0].state = .active;
conns[1].state = .closing;
try testing.expectEqual(@as(?usize, null), firstFree(conns));
conns[1].state = .free;
try testing.expectEqual(@as(?usize, 1), firstFree(conns));
try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot);
}
test "a claim takes the first free slot before shutdown" {
const conns = try testConns(2);
defer testing.allocator.free(conns);
conns[0].state = .active;
try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot);
}
test "a claim after shutdown is refused even with a free slot" {
const conns = try testConns(2);
defer testing.allocator.free(conns);
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
// The refusal must not consume the slot: `deinit` frees it, nothing else.
try testing.expectEqual(@as(?usize, 0), firstFree(conns));
}
test "shutdown outranks capacity" {
const conns = try testConns(1);
defer testing.allocator.free(conns);
conns[0].state = .active;
try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false)));
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
}
+1 -1
View File
@@ -310,6 +310,6 @@ test "the whole resolver answers over udp and tcp and fails over to a healthy up
try testing.expectEqual(@as(u64, 3), good.calls.load(.monotonic)); try testing.expectEqual(@as(u64, 3), good.calls.load(.monotonic));
udp.deinit(gpa, io); udp.deinit(gpa, io);
tcp.deinit(gpa, io); tcp.deinit(io);
group.cancel(io); group.cancel(io);
} }
+56 -415
View File
@@ -5,67 +5,43 @@
//! implemented here: a connection is answered serially until the client closes //! implemented here: a connection is answered serially until the client closes
//! it or the idle budget runs out. //! it or the idle budget runs out.
//! //!
//! Connection slots are fixed and pre-allocated. Over capacity the listener //! The slot pool, the accept loop and the shutdown protocol are
//! closes the new stream immediately and counts it; it never queues, and it //! `listener.Core`'s (milestone-18 ruling 1); this file is the per-connection
//! never allocates per connection. //! loop and nothing else. Connection slots are fixed and pre-allocated. Over
//! capacity the listener closes the new stream immediately and counts it; it
//! never queues, and it never allocates per connection.
//! //!
//! No stream read or write in 0.16.0 accepts a timeout, so every per-connection //! No stream read or write in 0.16.0 accepts a timeout, so every per-connection
//! operation is raced against `Options.idle_timeout` through `std.Io.Select` and //! operation is raced against `Options.idle_timeout` through `listener.race`
//! the loser is canceled. //! and the loser is canceled.
//!
//! Shutdown takes one of two paths, and they end the live connections
//! differently on purpose:
//!
//! - `deinit` shuts every active stream down first, so the connections unblock
//! and finish by themselves. `serve` then drains them, and a reply that was
//! half written still goes out whole.
//! - A canceled `serve` cannot drain. `deinit` is what would shut the streams
//! down, and it cannot run until `serve` returns — the composition root
//! cancels its task group before it releases anything (app.zig). Meanwhile
//! RFC 7766 §6.2.1.1 lets a client hold a connection open indefinitely by
//! asking again inside the idle budget, so draining would let one chatty
//! client stall the whole process's shutdown. The connections are canceled
//! instead, at the cost of the one reply that was mid-write.
//!
//! Either way `serve` returns only once no task can still touch a slot.
const std = @import("std"); const std = @import("std");
const address = @import("../platform/address.zig"); const address = @import("../platform/address.zig");
const handler = @import("handler.zig"); const handler = @import("handler.zig");
const listener = @import("listener.zig");
const transport = @import("../upstream/transport.zig"); const transport = @import("../upstream/transport.zig");
const log = std.log.scoped(.tcp_server);
/// The stream buffers only stage the framing bytes. A message longer than this /// The stream buffers only stage the framing bytes. A message longer than this
/// is read straight into `Conn.query` and written straight from `Conn.reply`, /// is read straight into `Conn.query` and written straight from `Conn.reply`,
/// so making them larger would buy nothing. /// so making them larger would buy nothing.
const stream_buffer_len = 1024; const stream_buffer_len = 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 };
pub const Options = struct { pub const Options = struct {
max_connections: u16 = 64, max_connections: u16 = 64,
/// RFC 7766 §6.2.3 recommends a few seconds of idle tolerance. /// RFC 7766 §6.2.3 recommends a few seconds of idle tolerance.
idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake }, idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake },
}; };
pub const Stats = struct { /// TCP/53 keeps no counter of its own: the shared six are exactly what it
accepted: std.atomic.Value(u64) = .init(0), /// counts.
rejected_at_capacity: std.atomic.Value(u64) = .init(0), pub const Stats = listener.CoreStats;
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),
idle_timeouts: std.atomic.Value(u64) = .init(0),
};
/// A plain copy of `Stats`, the shape `metrics.counterGroup` walks for the /// A plain copy of `Stats`, the shape `metrics.counterGroup` walks for the
/// `nxdns_tcp_server_*` families. Every counter is exported, including the /// `nxdns_tcp_server_*` families. Every counter is exported, including the
/// two refusals: a listener that turns clients away at capacity is the thing an /// two refusals: a listener that turns clients away at capacity is the thing an
/// operator most needs to see, and the module doc promises it is counted. /// operator most needs to see, and the module doc promises it is counted.
pub const Snapshot = struct { pub const Snapshot = struct {
accepted: u64, connections: u64,
rejected_at_capacity: u64, rejected_at_capacity: u64,
rejected_at_shutdown: u64, rejected_at_shutdown: u64,
accept_errors: u64, accept_errors: u64,
@@ -73,73 +49,36 @@ pub const Snapshot = struct {
idle_timeouts: u64, idle_timeouts: u64,
}; };
/// Lifecycle of the accept loop. `serve` claims `.serving`, `deinit` publishes
/// `.closing`, and the two meet at `stopped` so no task touches a connection
/// slot after it is freed.
const State = enum(u32) { idle, serving, closing };
/// `.closing` exists so `deinit` never shuts down a descriptor that its own
/// task is about to close: the transition to `.closing` happens under the mutex
/// before the close, and `deinit` only touches `.active` slots.
const ConnState = enum { free, active, closing };
/// Why the accept loop stopped, which decides what happens to the connections
/// still in flight.
const Stop = enum {
/// `deinit` published `.closing`. It has already shut every live connection
/// down, so each one is unblocked and finishing on its own.
closing,
/// This task is being canceled. Nothing has touched the connections.
canceled,
};
/// What the accept loop does with a stream it has just accepted.
const Claim = union(enum) {
/// The stream owns `conns[index]`.
slot: usize,
/// Every slot is taken. The stream is closed and the loop continues.
at_capacity,
/// `deinit` has started. The stream is closed and the loop returns.
shutting_down,
};
pub const TcpServer = struct { pub const TcpServer = struct {
server: std.Io.net.Server, core: listener.Core(Config),
handler: *handler.Handler, handler: *handler.Handler,
conns: []Conn,
mutex: std.Io.Mutex,
/// Guarded by `mutex`. `deinit` sets it in the same critical section that
/// shuts the active connections down, so a stream that arrives after that
/// scan can never claim a slot the scan will not visit again.
shutdown_begun: bool,
options: Options, options: Options,
stats: Stats,
state: std.atomic.Value(State),
stopped: std.Io.Event,
/// One slot is ~137 KiB, so the default 64 connections cost ~8.8 MiB, which /// One slot is ~137 KiB, so the default 64 connections cost ~8.8 MiB, which
/// is inside the PLAN §18 budget. The two message buffers cannot be shared /// is inside the PLAN §18 budget. The two message buffers cannot be shared
/// or shrunk: the handler holds the query while the reply is built, and /// or shrunk: the handler holds the query while the reply is built, and
/// both ceilings are the 65535 bytes the length prefix can express. /// both ceilings are the 65535 bytes the length prefix can express.
pub const Conn = struct { pub const Payload = struct {
query: [transport.max_message_len]u8, query: [transport.max_message_len]u8,
reply: [transport.max_message_len]u8, reply: [transport.max_message_len]u8,
read_buf: [stream_buffer_len]u8,
write_buf: [stream_buffer_len]u8,
/// The handler's per-query working memory. It belongs to the slot so /// The handler's per-query working memory. It belongs to the slot so
/// that answering a message allocates nothing, and a connection is /// that answering a message allocates nothing, and a connection is
/// answered serially, so one query uses it at a time. /// answered serially, so one query uses it at a time.
scratch: handler.Scratch, scratch: handler.Scratch,
stream: std.Io.net.Stream,
/// The client, read off the accepted socket once at claim time: every
/// message on this connection comes from the same peer, and the handler
/// needs it for rate limiting, groups and the query log.
peer: std.Io.net.IpAddress,
/// Guarded by `TcpServer.mutex`.
state: ConnState,
}; };
pub const ListenError = std.Io.net.IpAddress.ListenError || error{OutOfMemory}; const Config = struct {
pub const Owner = TcpServer;
pub const ConnPayload = Payload;
pub const serveConn = serveOne;
pub const read_buffer_len = stream_buffer_len;
pub const write_buffer_len = stream_buffer_len;
pub const log = std.log.scoped(.tcp_server);
pub const name = "tcp";
};
pub const Conn = listener.Core(Config).Conn;
pub const ListenError = listener.Core(Config).ListenError;
pub fn listen( pub fn listen(
gpa: std.mem.Allocator, gpa: std.mem.Allocator,
@@ -148,151 +87,49 @@ pub const TcpServer = struct {
h: *handler.Handler, h: *handler.Handler,
options: Options, options: Options,
) ListenError!TcpServer { ) ListenError!TcpServer {
std.debug.assert(options.max_connections > 0);
const conns = try gpa.alloc(Conn, options.max_connections);
errdefer gpa.free(conns);
for (conns) |*conn| conn.state = .free;
const local = listen_address;
const server = try local.listen(io, .{ .reuse_address = true });
return .{ return .{
.server = server, .core = try listener.Core(Config).listen(gpa, io, listen_address, options.max_connections),
.handler = h, .handler = h,
.conns = conns,
.mutex = .init,
.shutdown_begun = false,
.options = options, .options = options,
.stats = .{},
.state = .init(.idle),
.stopped = .unset,
}; };
} }
/// The kernel-assigned address. A port of 0 in `listen` resolves here. /// The kernel-assigned address. A port of 0 in `listen` resolves here.
pub fn boundAddress(self: *const TcpServer) std.Io.net.IpAddress { pub fn boundAddress(self: *const TcpServer) std.Io.net.IpAddress {
return self.server.socket.address; return self.core.boundAddress();
} }
/// The counters, read one at a time. A scrape that lands mid-accept can see /// The counters, read one at a time. A scrape that lands mid-accept can see
/// a connection counted before its outcome is; a lock would buy a /// a connection counted before its outcome is; a lock would buy a
/// consistency no consumer needs. /// consistency no consumer needs.
pub fn snapshotStats(self: *const TcpServer) Snapshot { pub fn snapshotStats(self: *const TcpServer) Snapshot {
const stats = &self.core.stats;
return .{ return .{
.accepted = self.stats.accepted.load(.monotonic), .connections = stats.connections.load(.monotonic),
.rejected_at_capacity = self.stats.rejected_at_capacity.load(.monotonic), .rejected_at_capacity = stats.rejected_at_capacity.load(.monotonic),
.rejected_at_shutdown = self.stats.rejected_at_shutdown.load(.monotonic), .rejected_at_shutdown = stats.rejected_at_shutdown.load(.monotonic),
.accept_errors = self.stats.accept_errors.load(.monotonic), .accept_errors = stats.accept_errors.load(.monotonic),
.connection_errors = self.stats.connection_errors.load(.monotonic), .connection_errors = stats.connection_errors.load(.monotonic),
.idle_timeouts = self.stats.idle_timeouts.load(.monotonic), .idle_timeouts = stats.idle_timeouts.load(.monotonic),
}; };
} }
/// Accept loop. Returns when the task is canceled or `deinit` stops it. /// Accept loop. Returns when the task is canceled or `deinit` stops it.
pub fn serve(self: *TcpServer, io: std.Io) void { pub fn serve(self: *TcpServer, io: std.Io) void {
if (self.state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return; self.core.serve(io);
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 already unblocked and ending on its
// own. Awaiting them means a half-written reply still goes out
// whole, and the wait is bounded by the shutdown, not the client.
.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: `deinit` cannot run
// until this task returns, and RFC 7766 lets a client hold a
// connection open forever by asking again inside the idle budget.
// Draining here would therefore let one client stall the whole
// process's shutdown for as long as it likes. `cancel` requests
// cancellation and joins, so the slots are still quiet — and the
// buffers still unreferenced — by the time `serve` returns; the
// price is the one reply that was mid-write.
.canceled => group.cancel(io),
}
self.stopped.set(io);
} }
pub fn deinit(self: *TcpServer, gpa: std.mem.Allocator, io: std.Io) void { pub fn deinit(self: *TcpServer, io: std.Io) void {
const was_serving = self.state.swap(.closing, .acq_rel) == .serving; self.core.deinit(io);
// Shutting the listening socket down is the documented way to unblock a
// pending `accept`: it fails with `error.SocketNotListening`.
const listener: std.Io.net.Stream = .{ .socket = self.server.socket };
listener.shutdown(io, .both) catch |err| {
log.debug("tcp listener shutdown failed: {t}", .{err});
};
// A live connection is blocked in a read that only the idle budget
// would end, which is seconds away. Shutting each one down bounds this,
// and the same critical section closes the door on new connections.
self.beginShutdown(io);
if (was_serving) self.stopped.waitUncancelable(io);
self.server.deinit(io);
gpa.free(self.conns);
self.* = undefined; self.* = undefined;
} }
fn acceptLoop(self: *TcpServer, io: std.Io, group: *std.Io.Group) Stop { /// One connection, answered serially until the client closes it, the idle
while (self.state.load(.acquire) == .serving) { /// budget runs out, or a framing error ends it. The core closes the slot
const stream = self.server.accept(io) catch |err| switch (err) { /// when this returns.
error.Canceled => return .canceled, fn serveOne(self: *TcpServer, io: std.Io, index: usize) void {
// `deinit` shuts the listening socket down to unblock exactly const conn = &self.core.conns[index];
// this call, so it is the shutdown path arriving early. const stats = &self.core.stats;
error.SocketNotListening => return .closing,
else => {
bump(&self.stats.accept_errors);
log.debug("tcp accept failed: {t}", .{err});
retry_delay.sleep(io) catch return .canceled;
continue;
},
};
const index = switch (self.claim(io, stream)) {
.slot => |index| index,
// Refusing now is honest; a queue would only hide the overload.
.at_capacity => {
bump(&self.stats.rejected_at_capacity);
stream.close(io);
continue;
},
// `deinit` will not see this stream in any slot, so serving it
// would hold `deinit` for the whole idle budget.
.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;
}
fn serveConn(self: *TcpServer, io: std.Io, index: usize) void {
defer self.finish(io, index);
const conn = &self.conns[index];
var reader = conn.stream.reader(io, &conn.read_buf); var reader = conn.stream.reader(io, &conn.read_buf);
var writer = conn.stream.writer(io, &conn.write_buf); var writer = conn.stream.writer(io, &conn.write_buf);
const budget = self.options.idle_timeout; const budget = self.options.idle_timeout;
@@ -300,15 +137,15 @@ pub const TcpServer = struct {
while (true) { while (true) {
var prefix: [transport.prefix_len]u8 = undefined; var prefix: [transport.prefix_len]u8 = undefined;
var got: usize = 0; var got: usize = 0;
switch (race(io, budget, readPrefix, .{ &reader.interface, &prefix, &got })) { switch (listener.race(io, budget, listener.readPrefix, .{ &reader.interface, &prefix, &got })) {
.ok => {}, .ok => {},
.timed_out => { .timed_out => {
bump(&self.stats.idle_timeouts); listener.bump(&stats.idle_timeouts);
return; return;
}, },
.canceled => return, .canceled => return,
.failed => { .failed => {
bump(&self.stats.connection_errors); listener.bump(&stats.connection_errors);
return; return;
}, },
} }
@@ -317,7 +154,7 @@ pub const TcpServer = struct {
// is the normal end of a connection, not a failure. // is the normal end of a connection, not a failure.
if (got == 0) return; if (got == 0) return;
if (got != transport.prefix_len) { if (got != transport.prefix_len) {
bump(&self.stats.connection_errors); listener.bump(&stats.connection_errors);
return; return;
} }
@@ -325,16 +162,16 @@ pub const TcpServer = struct {
// the prefix is a u16 so it can never exceed `max_message_len`. // the prefix is a u16 so it can never exceed `max_message_len`.
const len = transport.parsePrefix(prefix); const len = transport.parsePrefix(prefix);
if (len == 0) { if (len == 0) {
bump(&self.stats.connection_errors); listener.bump(&stats.connection_errors);
return; return;
} }
switch (race(io, budget, readBody, .{ &reader.interface, conn.query[0..len] })) { switch (listener.race(io, budget, listener.readBody, .{ &reader.interface, conn.payload.query[0..len] })) {
.ok => {}, .ok => {},
.canceled => return, .canceled => return,
// A half-sent message is a broken peer, not an idle one. // A half-sent message is a broken peer, not an idle one.
.timed_out, .failed => { .timed_out, .failed => {
bump(&self.stats.connection_errors); listener.bump(&stats.connection_errors);
return; return;
}, },
} }
@@ -343,9 +180,9 @@ pub const TcpServer = struct {
io, io,
.tcp, .tcp,
address.NetAddress.fromIp(conn.peer), address.NetAddress.fromIp(conn.peer),
conn.query[0..len], conn.payload.query[0..len],
&conn.reply, &conn.payload.reply,
&conn.scratch, &conn.payload.scratch,
); );
const bytes = switch (outcome) { const bytes = switch (outcome) {
// There is no framing for "no answer", so the connection ends. // There is no framing for "no answer", so the connection ends.
@@ -354,210 +191,14 @@ pub const TcpServer = struct {
}; };
const out = transport.framePrefix(@intCast(bytes.len)); const out = transport.framePrefix(@intCast(bytes.len));
switch (race(io, budget, writeReply, .{ &writer.interface, &out, bytes })) { switch (listener.race(io, budget, listener.writeReply, .{ &writer.interface, &out, bytes })) {
.ok => {}, .ok => {},
.canceled => return, .canceled => return,
.timed_out, .failed => { .timed_out, .failed => {
bump(&self.stats.connection_errors); listener.bump(&stats.connection_errors);
return; return;
}, },
} }
} }
} }
fn claim(self: *TcpServer, io: std.Io, stream: std.Io.net.Stream) Claim {
// Uncancelable: this section takes no Io and never blocks on a peer, so
// it cannot deadlock, and losing the lock mid-update would leak a slot.
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].state = .active;
},
.at_capacity, .shutting_down => {},
}
return outcome;
}
fn finish(self: *TcpServer, io: std.Io, index: usize) void {
const conn = &self.conns[index];
self.mutex.lockUncancelable(io);
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.state = .free;
self.mutex.unlock(io);
}
/// Closes the door on new connections and unblocks the live ones. Both
/// happen under one hold of the mutex: a `claim` that runs before this
/// leaves an `.active` slot the loop below shuts down, and a `claim` that
/// runs after it reads `shutdown_begun` and takes no slot at all.
fn beginShutdown(self: *TcpServer, io: std.Io) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.shutdown_begun = true;
for (self.conns) |*conn| {
if (conn.state != .active) continue;
conn.stream.shutdown(io, .both) catch |err| {
log.debug("tcp connection shutdown failed: {t}", .{err});
};
}
}
}; };
/// The capacity rule, without the mutex, so it is testable without a backend.
fn firstFree(conns: []const TcpServer.Conn) ?usize {
for (conns, 0..) |*conn, index| {
if (conn.state == .free) return index;
}
return null;
}
/// The whole claim rule, without the mutex. Shutdown outranks capacity: a free
/// slot is still refused once `deinit` has passed the connections.
fn decideClaim(conns: []const TcpServer.Conn, shutdown_begun: bool) Claim {
if (shutdown_begun) return .shutting_down;
const index = firstFree(conns) orelse return .at_capacity;
return .{ .slot = index };
}
const Outcome = union(enum) {
op: anyerror!void,
expiry: std.Io.Cancelable!void,
};
const Result = enum { ok, timed_out, failed, canceled };
/// Runs one connection operation against the idle budget and cancels the loser.
fn race(
io: std.Io,
budget: std.Io.Clock.Duration,
comptime f: anytype,
args: std.meta.ArgsTuple(@TypeOf(f)),
) Result {
var outcomes: [2]Outcome = undefined;
var select: std.Io.Select(Outcome) = .init(io, &outcomes);
defer select.cancelDiscard();
select.concurrent(.op, f, args) catch |err| switch (err) {
error.ConcurrencyUnavailable => return .failed,
};
select.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return .failed,
};
return switch (select.await() catch return .canceled) {
.op => |result| if (result) |_| .ok else |err| switch (err) {
error.Canceled => .canceled,
else => .failed,
},
// A canceled sleep means this task is being torn down, not that the
// client went idle.
.expiry => |result| if (result) |_| .timed_out else |_| .canceled,
};
}
fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void {
return budget.sleep(io);
}
/// `readSliceShort` rather than `readSliceAll`: a zero-length read is a client
/// that closed cleanly between messages, and only a partial prefix is an error.
fn readPrefix(reader: *std.Io.Reader, buf: *[transport.prefix_len]u8, out_len: *usize) anyerror!void {
out_len.* = try reader.readSliceShort(buf);
}
fn readBody(reader: *std.Io.Reader, buf: []u8) anyerror!void {
return reader.readSliceAll(buf);
}
fn writeReply(writer: *std.Io.Writer, prefix: *const [transport.prefix_len]u8, bytes: []const u8) anyerror!void {
try writer.writeAll(prefix);
try writer.writeAll(bytes);
try writer.flush();
}
fn bump(counter: *std.atomic.Value(u64)) void {
_ = counter.fetchAdd(1, .monotonic);
}
const testing = std.testing;
fn testConns(count: usize) ![]TcpServer.Conn {
const conns = try testing.allocator.alloc(TcpServer.Conn, count);
for (conns) |*conn| conn.state = .free;
return conns;
}
test "the connection pool hands out every slot once" {
const conns = try testConns(3);
defer testing.allocator.free(conns);
for (0..conns.len) |expected| {
const index = firstFree(conns) orelse return error.TestUnexpectedResult;
try testing.expectEqual(expected, index);
conns[index].state = .active;
}
}
test "a full connection pool refuses instead of growing" {
const conns = try testConns(2);
defer testing.allocator.free(conns);
for (conns) |*conn| conn.state = .active;
try testing.expectEqual(@as(?usize, null), firstFree(conns));
}
test "a closing slot is not reused until it is free" {
const conns = try testConns(2);
defer testing.allocator.free(conns);
conns[0].state = .active;
conns[1].state = .closing;
try testing.expectEqual(@as(?usize, null), firstFree(conns));
conns[1].state = .free;
try testing.expectEqual(@as(?usize, 1), firstFree(conns));
}
test "a claim takes the first free slot before shutdown" {
const conns = try testConns(2);
defer testing.allocator.free(conns);
conns[0].state = .active;
try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot);
}
test "a claim after shutdown is refused even with a free slot" {
const conns = try testConns(2);
defer testing.allocator.free(conns);
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
// The refusal must not consume the slot: `deinit` frees it, nothing else.
try testing.expectEqual(@as(?usize, 0), firstFree(conns));
}
test "shutdown outranks capacity" {
const conns = try testConns(1);
defer testing.allocator.free(conns);
conns[0].state = .active;
try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false)));
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
}
+15 -15
View File
@@ -184,11 +184,11 @@ test "two length-prefixed queries share one connection" {
try bounded(io, twoQueriesOnOneConnection, .{ io, server_address }); try bounded(io, twoQueriesOnOneConnection, .{ io, server_address });
try testing.expectEqual(@as(u64, 1), server.stats.accepted.load(.monotonic)); try testing.expectEqual(@as(u64, 1), server.core.stats.connections.load(.monotonic));
try testing.expectEqual(@as(u64, 0), server.stats.rejected_at_capacity.load(.monotonic)); try testing.expectEqual(@as(u64, 0), server.core.stats.rejected_at_capacity.load(.monotonic));
try testing.expectEqual(@as(u64, 2), h.stats.queries.load(.monotonic)); try testing.expectEqual(@as(u64, 2), h.stats.queries.load(.monotonic));
server.deinit(gpa, io); server.deinit(io);
group.await(io) catch |err| switch (err) { group.await(io) catch |err| switch (err) {
error.Canceled => unreachable, error.Canceled => unreachable,
}; };
@@ -218,12 +218,12 @@ test "the claimed slot records the connecting client" {
// was written after `claim` filled the slot in, so this read races nothing. // was written after `claim` filled the slot in, so this read races nothing.
// Without a real peer the handler would rate-limit, group and log every TCP // Without a real peer the handler would rate-limit, group and log every TCP
// client under whatever the uninitialized slot happened to hold. // client under whatever the uninitialized slot happened to hold.
const peer = server.conns[0].peer; const peer = server.core.conns[0].peer;
try testing.expectEqual(net.IpAddress.ip4, std.meta.activeTag(peer)); try testing.expectEqual(net.IpAddress.ip4, std.meta.activeTag(peer));
try testing.expectEqualSlices(u8, &[_]u8{ 127, 0, 0, 1 }, &peer.ip4.bytes); try testing.expectEqualSlices(u8, &[_]u8{ 127, 0, 0, 1 }, &peer.ip4.bytes);
try testing.expect(peer.ip4.port != 0); try testing.expect(peer.ip4.port != 0);
server.deinit(gpa, io); server.deinit(io);
group.await(io) catch |err| switch (err) { group.await(io) catch |err| switch (err) {
error.Canceled => unreachable, error.Canceled => unreachable,
}; };
@@ -317,7 +317,7 @@ test "a canceled serve does not wait for a live connection" {
try testing.expectEqual(@as(?usize, 0), firstFreeSlot(&server)); try testing.expectEqual(@as(?usize, 0), firstFreeSlot(&server));
client_group.cancel(io); client_group.cancel(io);
server.deinit(gpa, io); server.deinit(io);
// Checked last: the connection had to be answered for the test to mean // Checked last: the connection had to be answered for the test to mean
// anything, and the server is torn down before a failure is reported. // anything, and the server is torn down before a failure is reported.
@@ -327,7 +327,7 @@ test "a canceled serve does not wait for a live connection" {
/// The first slot the server would hand out, read after `serve` has returned so /// The first slot the server would hand out, read after `serve` has returned so
/// nothing can be writing it. /// nothing can be writing it.
fn firstFreeSlot(server: *const tcp_server.TcpServer) ?usize { fn firstFreeSlot(server: *const tcp_server.TcpServer) ?usize {
for (server.conns, 0..) |*conn, index| { for (server.core.conns, 0..) |*conn, index| {
if (conn.state == .free) return index; if (conn.state == .free) return index;
} }
return null; return null;
@@ -356,11 +356,11 @@ test "an idle connection is closed and counted" {
try bounded(io, waitForServerClose, .{ io, server_address }); try bounded(io, waitForServerClose, .{ io, server_address });
try testing.expectEqual(@as(u64, 1), server.stats.accepted.load(.monotonic)); try testing.expectEqual(@as(u64, 1), server.core.stats.connections.load(.monotonic));
try testing.expectEqual(@as(u64, 1), server.stats.idle_timeouts.load(.monotonic)); try testing.expectEqual(@as(u64, 1), server.core.stats.idle_timeouts.load(.monotonic));
try testing.expectEqual(@as(u64, 0), server.stats.connection_errors.load(.monotonic)); try testing.expectEqual(@as(u64, 0), server.core.stats.connection_errors.load(.monotonic));
server.deinit(gpa, io); server.deinit(io);
group.await(io) catch |err| switch (err) { group.await(io) catch |err| switch (err) {
error.Canceled => unreachable, error.Canceled => unreachable,
}; };
@@ -389,10 +389,10 @@ test "a zero-length message is a connection error" {
try bounded(io, sendZeroLength, .{ io, server_address }); try bounded(io, sendZeroLength, .{ io, server_address });
try testing.expectEqual(@as(u64, 1), server.stats.connection_errors.load(.monotonic)); try testing.expectEqual(@as(u64, 1), server.core.stats.connection_errors.load(.monotonic));
try testing.expectEqual(@as(u64, 0), server.stats.idle_timeouts.load(.monotonic)); try testing.expectEqual(@as(u64, 0), server.core.stats.idle_timeouts.load(.monotonic));
server.deinit(gpa, io); server.deinit(io);
group.await(io) catch |err| switch (err) { group.await(io) catch |err| switch (err) {
error.Canceled => unreachable, error.Canceled => unreachable,
}; };
@@ -436,7 +436,7 @@ test "deinit ends a serve loop that is blocked on accept" {
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io }); try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
// No client ever connects, so `serve` is inside an accept when this runs. // No client ever connects, so `serve` is inside an accept when this runs.
server.deinit(gpa, io); server.deinit(io);
group.await(io) catch |err| switch (err) { group.await(io) catch |err| switch (err) {
error.Canceled => unreachable, error.Canceled => unreachable,
}; };
+47 -89
View File
@@ -38,35 +38,23 @@ const list_clients_sql =
/// Every string in the result is a heap copy owned by `gpa`. /// Every string in the result is a heap copy owned by `gpa`.
pub fn listClients(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Client) { pub fn listClients(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Client) {
var stmt = try database.prepare(list_clients_sql); return crud.listRows(model.Client, database, gpa, list_clients_sql, readClient);
defer stmt.deinit(); }
var out: std.ArrayList(model.Client) = .empty; fn readClient(stmt: *db.Stmt, gpa: Allocator) db.Error!model.Client {
// `errdefer`s run in reverse: `freeClients` is declared last so it runs const ip = try stmt.columnTextAlloc(gpa, 0);
// before the backing array is released. errdefer gpa.free(ip);
errdefer out.deinit(gpa); // `clients.name` is nullable; `columnTextAlloc` reads NULL as "", which is
errdefer freeClients(gpa, out.items); // exactly the model's default.
const name = try stmt.columnTextAlloc(gpa, 1);
while (try stmt.step()) { errdefer gpa.free(name);
const ip = try stmt.columnTextAlloc(gpa, 0); const group = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(ip); errdefer gpa.free(group);
// `clients.name` is nullable; `columnTextAlloc` reads NULL as "", which return .{ .ip = ip, .name = name, .group = group };
// is exactly the model's default.
const name = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(name);
const group = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(group);
try out.append(gpa, .{ .ip = ip, .name = name, .group = group });
}
return out;
} }
pub fn freeClients(gpa: Allocator, items: []const model.Client) void { pub fn freeClients(gpa: Allocator, items: []const model.Client) void {
for (items) |item| { crud.freeRows(model.Client, gpa, items);
gpa.free(item.ip);
gpa.free(item.name);
gpa.free(item.group);
}
} }
const insert_client_sql = const insert_client_sql =
@@ -154,31 +142,22 @@ const list_client_prefixes_sql =
; ;
pub fn listClientPrefixes(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ClientPrefix) { pub fn listClientPrefixes(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ClientPrefix) {
var stmt = try database.prepare(list_client_prefixes_sql); return crud.listRows(model.ClientPrefix, database, gpa, list_client_prefixes_sql, readClientPrefix);
defer stmt.deinit(); }
var out: std.ArrayList(model.ClientPrefix) = .empty; fn readClientPrefix(stmt: *db.Stmt, gpa: Allocator) db.Error!model.ClientPrefix {
errdefer out.deinit(gpa); const prefix = try stmt.columnTextAlloc(gpa, 0);
errdefer freeClientPrefixes(gpa, out.items); errdefer gpa.free(prefix);
const group = try stmt.columnTextAlloc(gpa, 1);
while (try stmt.step()) { errdefer gpa.free(group);
const prefix = try stmt.columnTextAlloc(gpa, 0); // The column is a 64-bit integer; the model field is `i32`. A value outside
errdefer gpa.free(prefix); // that range means something other than nxdns wrote the row.
const group = try stmt.columnTextAlloc(gpa, 1); const priority = std.math.cast(i32, stmt.columnInt(2)) orelse return error.Mismatch;
errdefer gpa.free(group); return .{ .prefix = prefix, .group = group, .priority = priority };
// The column is a 64-bit integer; the model 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;
try out.append(gpa, .{ .prefix = prefix, .group = group, .priority = priority });
}
return out;
} }
pub fn freeClientPrefixes(gpa: Allocator, items: []const model.ClientPrefix) void { pub fn freeClientPrefixes(gpa: Allocator, items: []const model.ClientPrefix) void {
for (items) |item| { crud.freeRows(model.ClientPrefix, gpa, items);
gpa.free(item.prefix);
gpa.free(item.group);
}
} }
pub fn insertClientPrefix(database: *db.Db, item: model.ClientPrefix, ctx: InsertContext) db.Error!void { pub fn insertClientPrefix(database: *db.Db, item: model.ClientPrefix, ctx: InsertContext) db.Error!void {
@@ -257,29 +236,15 @@ const get_client_sql =
/// Every client, materialised ones included. Every string is a heap copy owned /// Every client, materialised ones included. Every string is a heap copy owned
/// by `gpa`. /// by `gpa`.
pub fn listClientRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientRow) { pub fn listClientRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientRow) {
var stmt = try database.prepare(list_client_rows_sql); return crud.listRows(ClientRow, database, gpa, list_client_rows_sql, readClientRow);
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 { pub fn freeClientRow(gpa: Allocator, row: ClientRow) void {
gpa.free(row.ip); crud.freeRow(ClientRow, gpa, row);
gpa.free(row.name);
gpa.free(row.group);
} }
pub fn freeClientRows(gpa: Allocator, items: []const ClientRow) void { pub fn freeClientRows(gpa: Allocator, items: []const ClientRow) void {
for (items) |item| freeClientRow(gpa, item); crud.freeRows(ClientRow, gpa, items);
} }
pub fn getClient(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ClientRow { pub fn getClient(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ClientRow {
@@ -383,39 +348,32 @@ const list_client_prefix_rows_sql =
/// Same order as `listClientPrefixes`; every string is a heap copy owned by /// Same order as `listClientPrefixes`; every string is a heap copy owned by
/// `gpa`. /// `gpa`.
pub fn listClientPrefixRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientPrefixRow) { pub fn listClientPrefixRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientPrefixRow) {
var stmt = try database.prepare(list_client_prefix_rows_sql); return crud.listRows(ClientPrefixRow, database, gpa, list_client_prefix_rows_sql, readClientPrefixRow);
defer stmt.deinit(); }
var out: std.ArrayList(ClientPrefixRow) = .empty; fn readClientPrefixRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ClientPrefixRow {
errdefer out.deinit(gpa); const prefix = try stmt.columnTextAlloc(gpa, 1);
errdefer freeClientPrefixRows(gpa, out.items); errdefer gpa.free(prefix);
const group = try stmt.columnTextAlloc(gpa, 3);
while (try stmt.step()) { errdefer gpa.free(group);
const prefix = try stmt.columnTextAlloc(gpa, 1); // The column is a 64-bit integer; the row field is `i32`. A value outside
errdefer gpa.free(prefix); // that range means something other than nxdns wrote the row.
const group = try stmt.columnTextAlloc(gpa, 3); const priority = std.math.cast(i32, stmt.columnInt(4)) orelse return error.Mismatch;
errdefer gpa.free(group); return .{
// The column is a 64-bit integer; the row field is `i32`. A value .id = stmt.columnInt(0),
// outside that range means something other than nxdns wrote the row. .prefix = prefix,
const priority = std.math.cast(i32, stmt.columnInt(4)) orelse return error.Mismatch; .group_id = stmt.columnInt(2),
try out.append(gpa, .{ .group = group,
.id = stmt.columnInt(0), .priority = priority,
.prefix = prefix, };
.group_id = stmt.columnInt(2),
.group = group,
.priority = priority,
});
}
return out;
} }
pub fn freeClientPrefixRow(gpa: Allocator, row: ClientPrefixRow) void { pub fn freeClientPrefixRow(gpa: Allocator, row: ClientPrefixRow) void {
gpa.free(row.prefix); crud.freeRow(ClientPrefixRow, gpa, row);
gpa.free(row.group);
} }
pub fn freeClientPrefixRows(gpa: Allocator, items: []const ClientPrefixRow) void { pub fn freeClientPrefixRows(gpa: Allocator, items: []const ClientPrefixRow) void {
for (items) |item| freeClientPrefixRow(gpa, item); crud.freeRows(ClientPrefixRow, gpa, items);
} }
/// Replaces the whole prefix table inside a transaction (ruling 9 makes /// Replaces the whole prefix table inside a transaction (ruling 9 makes
+190
View File
@@ -8,8 +8,14 @@
//! `error.Constraint` needs no helper — `Stmt.exec` already reports it, and the //! `error.Constraint` needs no helper — `Stmt.exec` already reports it, and the
//! handler layer maps it to 409. Each mutation documents which constraint of //! handler layer maps it to 409. Each mutation documents which constraint of
//! `config_schema.ddl_v1` can fire. //! `config_schema.ddl_v1` can fire.
//!
//! `listRows` and `freeRows` are the read half. Every `list*` function in this
//! directory reads rows into a `std.ArrayList` under the same unwind rules, and
//! the errdefer ordering those rules need is easy to write backwards. It is
//! written once here.
const std = @import("std"); const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig"); const db = @import("../db.zig");
const migrations = @import("../migrations.zig"); const migrations = @import("../migrations.zig");
@@ -25,6 +31,102 @@ pub fn execStrict(database: *db.Db, stmt: *db.Stmt) db.Error!void {
if (database.changes() == 0) return error.NotFound; if (database.changes() == 0) return error.NotFound;
} }
/// Reads every row `sql` produces into a list, with the memory-safety
/// choreography every `list*` function in this directory shares.
///
/// `readRow` allocates the row's owning fields from `gpa` and carries its own
/// per-column `errdefer`s, so a row that fails halfway releases the columns it
/// already read. This function owns everything around that: a failure after the
/// first append releases the rows already in the list and then the list itself.
///
/// The result is the caller's: free the rows with `freeRows` (or the repository
/// shim over it) and then `deinit` the list.
pub fn listRows(
comptime Row: type,
database: *db.Db,
gpa: Allocator,
comptime sql: []const u8,
comptime readRow: fn (*db.Stmt, Allocator) db.Error!Row,
) db.Error!std.ArrayList(Row) {
return listRowsBound(Row, database, gpa, sql, readRow, .{});
}
/// `listRows` for a statement with parameters. `args` is a tuple bound to
/// positions 1..n in order.
pub fn listRowsBound(
comptime Row: type,
database: *db.Db,
gpa: Allocator,
comptime sql: []const u8,
comptime readRow: fn (*db.Stmt, Allocator) db.Error!Row,
args: anytype,
) db.Error!std.ArrayList(Row) {
var stmt = try database.prepare(sql);
defer stmt.deinit();
inline for (args, 0..) |arg, i| try bindArg(&stmt, i + 1, arg);
var out: std.ArrayList(Row) = .empty;
// Order matters: `errdefer`s run in reverse, so the free pass is declared
// *after* `deinit` to run *before* it. The other order reads `out.items`
// after the backing array is gone.
errdefer out.deinit(gpa);
errdefer freeRows(Row, gpa, out.items);
while (try stmt.step()) {
const row = try readRow(&stmt, gpa);
errdefer freeRow(Row, gpa, row);
try out.append(gpa, row);
}
return out;
}
fn bindArg(stmt: *db.Stmt, idx: c_int, arg: anytype) db.Error!void {
const Arg = @TypeOf(arg);
if (Arg == []const u8 or Arg == []u8) return stmt.bindText(idx, arg);
return switch (@typeInfo(Arg)) {
.bool => stmt.bindInt(idx, @intFromBool(arg)),
.int, .comptime_int => stmt.bindInt(idx, arg),
else => @compileError("crud.listRowsBound: cannot bind a " ++ @typeName(Arg)),
};
}
/// Releases every owning field of every row `listRows` produced.
pub fn freeRows(comptime Row: type, gpa: Allocator, items: []const Row) void {
for (items) |item| freeRow(Row, gpa, item);
}
/// Releases the owning fields of one row.
///
/// A repository row owns its heap memory in exactly two shapes: `[]const u8`
/// and `?[]const u8` (`SourceRow.checksum` is the optional one — a shallow
/// slice-only reflection would leak its payload). Every other field must be a
/// plain value the row does not own. A field of any other shape is a
/// `@compileError`, so a row that grows a nested allocation cannot start
/// leaking silently: whoever adds it has to teach this function first.
pub fn freeRow(comptime Row: type, gpa: Allocator, row: Row) void {
switch (@typeInfo(Row)) {
.@"struct" => |info| inline for (info.fields) |field| {
freeField(field.type, @typeName(Row) ++ "." ++ field.name, gpa, @field(row, field.name));
},
else => freeField(Row, @typeName(Row), gpa, row),
}
}
fn freeField(comptime Field: type, comptime where: []const u8, gpa: Allocator, value: Field) void {
if (Field == []const u8 or Field == []u8) return gpa.free(value);
if (Field == ?[]const u8 or Field == ?[]u8) return if (value) |owned| gpa.free(owned);
comptime assertUnowning(Field, where);
}
fn assertUnowning(comptime Field: type, comptime where: []const u8) void {
switch (@typeInfo(Field)) {
.bool, .int, .float, .@"enum", .void => {},
.optional => |info| assertUnowning(info.child, where),
else => @compileError("crud.freeRow: " ++ where ++ " is a " ++ @typeName(Field) ++
", which is neither a plain value nor an owning slice; teach freeRow how to release it"),
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// tests // tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -78,3 +180,91 @@ test "execStrict reports NotFound for an id no row holds" {
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM forward_zones")); try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM forward_zones"));
} }
/// Carries both owning shapes a repository row may hold: a `[]const u8` that is
/// always there, and the `?[]const u8` of `SourceRow.checksum`.
const TestRow = struct {
id: i64,
url: []const u8,
checksum: ?[]const u8,
};
const test_rows_sql = "SELECT id, url, checksum FROM blocklist_sources ORDER BY id";
fn readTestRow(stmt: *db.Stmt, gpa: Allocator) db.Error!TestRow {
const url = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(url);
const checksum = try stmt.columnTextAllocOrNull(gpa, 2);
errdefer if (checksum) |value| gpa.free(value);
return .{ .id = stmt.columnInt(0), .url = url, .checksum = checksum };
}
/// Two rows: the first carries a checksum, the second leaves it NULL, so one
/// read exercises both arms of the optional.
fn seedTestRows(database: *db.Db) !void {
try database.exec(
\\INSERT INTO blocklist_sources (id, url, name, checksum) VALUES
\\ (1, 'https://a.example/list.txt', 'A', 'aaaa'),
\\ (2, 'https://b.example/list.txt', 'B', NULL);
);
}
test "listRows reads every row and freeRows releases both owning shapes" {
var database = try openTable();
defer database.close();
try seedTestRows(&database);
var rows = try listRows(TestRow, &database, testing.allocator, test_rows_sql, readTestRow);
defer rows.deinit(testing.allocator);
defer freeRows(TestRow, testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 2), rows.items.len);
try testing.expectEqualStrings("https://a.example/list.txt", rows.items[0].url);
// The leak detector is what proves this payload is released.
try testing.expectEqualStrings("aaaa", rows.items[0].checksum.?);
try testing.expectEqual(@as(?[]const u8, null), rows.items[1].checksum);
}
fn listRowsUnderFailure(gpa: Allocator) !void {
var database = try openTable();
defer database.close();
try seedTestRows(&database);
var rows = try listRows(TestRow, &database, gpa, test_rows_sql, readTestRow);
defer rows.deinit(gpa);
defer freeRows(TestRow, gpa, rows.items);
// A row with a non-null checksum must be in the result, or the failure
// injection never reaches the optional's allocation.
try testing.expect(rows.items[0].checksum != null);
}
test "listRows is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listRowsUnderFailure, .{});
}
test "listRowsBound binds its arguments in tuple order" {
var database = try openTable();
defer database.close();
try seedTestRows(&database);
var rows = try listRowsBound(
TestRow,
&database,
testing.allocator,
"SELECT id, url, checksum FROM blocklist_sources WHERE id > ?1 AND name = ?2",
readTestRow,
.{ @as(i64, 1), @as([]const u8, "B") },
);
defer rows.deinit(testing.allocator);
defer freeRows(TestRow, testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 1), rows.items.len);
try testing.expectEqual(@as(i64, 2), rows.items[0].id);
}
test "freeRows over a row type with no owning field is a no-op" {
// `listGroupSourceIds` reads a bare `i64`; the reflection must accept a Row
// that is not a struct at all.
freeRows(i64, testing.allocator, &.{ 1, 2, 3 });
}
+49 -64
View File
@@ -27,26 +27,23 @@ const InsertContext = context.InsertContext;
/// Every string in the result is a heap copy owned by `gpa`; free the whole /// Every string in the result is a heap copy owned by `gpa`; free the whole
/// list with `freeGroups` and then `deinit` the list itself. /// list with `freeGroups` and then `deinit` the list itself.
pub fn listGroups(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Group) { pub fn listGroups(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Group) {
var stmt = try database.prepare("SELECT name, safe_search FROM groups ORDER BY name"); return crud.listRows(
defer stmt.deinit(); model.Group,
database,
gpa,
"SELECT name, safe_search FROM groups ORDER BY name",
readGroup,
);
}
var out: std.ArrayList(model.Group) = .empty; fn readGroup(stmt: *db.Stmt, gpa: Allocator) db.Error!model.Group {
// Order matters: `errdefer`s run in reverse, so `freeGroups` must be const name = try stmt.columnTextAlloc(gpa, 0);
// declared *after* `deinit` to run *before* it. The other order reads errdefer gpa.free(name);
// `out.items` after the backing array is gone. return .{ .name = name, .safe_search = stmt.columnBool(1) };
errdefer out.deinit(gpa);
errdefer freeGroups(gpa, out.items);
while (try stmt.step()) {
const name = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(name);
try out.append(gpa, .{ .name = name, .safe_search = stmt.columnBool(1) });
}
return out;
} }
pub fn freeGroups(gpa: Allocator, items: []const model.Group) void { pub fn freeGroups(gpa: Allocator, items: []const model.Group) void {
for (items) |item| gpa.free(item.name); crud.freeRows(model.Group, gpa, items);
} }
pub fn insertGroup(database: *db.Db, item: model.Group, ctx: InsertContext) db.Error!void { pub fn insertGroup(database: *db.Db, item: model.Group, ctx: InsertContext) db.Error!void {
@@ -91,28 +88,19 @@ const list_group_sources_sql =
/// The two foreign keys are `NOT NULL` and enforced, so the join is total: a /// The two foreign keys are `NOT NULL` and enforced, so the join is total: a
/// `group_sources` row can never be dropped by it. /// `group_sources` row can never be dropped by it.
pub fn listGroupSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.GroupSource) { pub fn listGroupSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.GroupSource) {
var stmt = try database.prepare(list_group_sources_sql); return crud.listRows(model.GroupSource, database, gpa, list_group_sources_sql, readGroupSource);
defer stmt.deinit(); }
var out: std.ArrayList(model.GroupSource) = .empty; fn readGroupSource(stmt: *db.Stmt, gpa: Allocator) db.Error!model.GroupSource {
errdefer out.deinit(gpa); const group = try stmt.columnTextAlloc(gpa, 0);
errdefer freeGroupSources(gpa, out.items); errdefer gpa.free(group);
const source_url = try stmt.columnTextAlloc(gpa, 1);
while (try stmt.step()) { errdefer gpa.free(source_url);
const group = try stmt.columnTextAlloc(gpa, 0); return .{ .group = group, .source_url = source_url };
errdefer gpa.free(group);
const source_url = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(source_url);
try out.append(gpa, .{ .group = group, .source_url = source_url });
}
return out;
} }
pub fn freeGroupSources(gpa: Allocator, items: []const model.GroupSource) void { pub fn freeGroupSources(gpa: Allocator, items: []const model.GroupSource) void {
for (items) |item| { crud.freeRows(model.GroupSource, gpa, items);
gpa.free(item.group);
gpa.free(item.source_url);
}
} }
pub fn insertGroupSource(database: *db.Db, item: model.GroupSource, ctx: InsertContext) db.Error!void { pub fn insertGroupSource(database: *db.Db, item: model.GroupSource, ctx: InsertContext) db.Error!void {
@@ -145,31 +133,27 @@ 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`. /// 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) { 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"); return crud.listRows(
defer stmt.deinit(); GroupRow,
database,
gpa,
"SELECT id, name, safe_search FROM groups ORDER BY name",
readGroupRow,
);
}
var out: std.ArrayList(GroupRow) = .empty; fn readGroupRow(stmt: *db.Stmt, gpa: Allocator) db.Error!GroupRow {
errdefer out.deinit(gpa); const name = try stmt.columnTextAlloc(gpa, 1);
errdefer freeGroupRows(gpa, out.items); errdefer gpa.free(name);
return .{ .id = stmt.columnInt(0), .name = name, .safe_search = stmt.columnBool(2) };
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 { pub fn freeGroupRow(gpa: Allocator, row: GroupRow) void {
gpa.free(row.name); crud.freeRow(GroupRow, gpa, row);
} }
pub fn freeGroupRows(gpa: Allocator, items: []const GroupRow) void { pub fn freeGroupRows(gpa: Allocator, items: []const GroupRow) void {
for (items) |item| freeGroupRow(gpa, item); crud.freeRows(GroupRow, gpa, items);
} }
pub fn getGroup(database: *db.Db, gpa: Allocator, id: i64) db.Error!?GroupRow { pub fn getGroup(database: *db.Db, gpa: Allocator, id: i64) db.Error!?GroupRow {
@@ -177,11 +161,7 @@ pub fn getGroup(database: *db.Db, gpa: Allocator, id: i64) db.Error!?GroupRow {
defer stmt.deinit(); defer stmt.deinit();
try stmt.bindInt(1, id); try stmt.bindInt(1, id);
if (!try stmt.step()) return null; if (!try stmt.step()) return null;
return .{ return try readGroupRow(&stmt, gpa);
.id = stmt.columnInt(0),
.name = try stmt.columnTextAlloc(gpa, 1),
.safe_search = stmt.columnBool(2),
};
} }
/// `error.Constraint`: `groups.name` is UNIQUE. /// `error.Constraint`: `groups.name` is UNIQUE.
@@ -220,14 +200,19 @@ pub fn deleteGroup(database: *db.Db, id: i64) db.Error!void {
/// `group_id` yields an empty list, not an error: the caller that needs the /// `group_id` yields an empty list, not an error: the caller that needs the
/// distinction reads the group itself. /// distinction reads the group itself.
pub fn listGroupSourceIds(database: *db.Db, gpa: Allocator, group_id: i64) db.Error!std.ArrayList(i64) { 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"); return crud.listRowsBound(
defer stmt.deinit(); i64,
try stmt.bindInt(1, group_id); database,
gpa,
"SELECT source_id FROM group_sources WHERE group_id = ?1 ORDER BY source_id",
readSourceId,
.{group_id},
);
}
var out: std.ArrayList(i64) = .empty; fn readSourceId(stmt: *db.Stmt, gpa: Allocator) db.Error!i64 {
errdefer out.deinit(gpa); _ = gpa;
while (try stmt.step()) try out.append(gpa, stmt.columnInt(0)); return stmt.columnInt(0);
return out;
} }
/// Replaces one group's whole source assignment inside a transaction, so a /// Replaces one group's whole source assignment inside a transaction, so a
+40 -74
View File
@@ -25,34 +25,23 @@ const list_local_records_sql =
/// Every string in the result is a heap copy owned by `gpa`. /// Every string in the result is a heap copy owned by `gpa`.
pub fn listLocalRecords(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.LocalRecord) { pub fn listLocalRecords(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.LocalRecord) {
var stmt = try database.prepare(list_local_records_sql); return crud.listRows(model.LocalRecord, database, gpa, list_local_records_sql, readLocalRecord);
defer stmt.deinit(); }
var out: std.ArrayList(model.LocalRecord) = .empty; fn readLocalRecord(stmt: *db.Stmt, gpa: Allocator) db.Error!model.LocalRecord {
// `errdefer`s run in reverse: the free pass is declared last so it runs // The DDL's CHECK constraint makes the decode total for any row nxdns
// before the backing array is released. // wrote; `error.Mismatch` covers a row that something else wrote.
errdefer out.deinit(gpa); const rtype = model.RecordType.fromDb(stmt.columnText(1)) orelse return error.Mismatch;
errdefer freeLocalRecords(gpa, out.items); const ttl = std.math.cast(u32, stmt.columnInt(3)) orelse return error.Mismatch;
const name = try stmt.columnTextAlloc(gpa, 0);
while (try stmt.step()) { errdefer gpa.free(name);
const name = try stmt.columnTextAlloc(gpa, 0); const value = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(name); errdefer gpa.free(value);
const value = try stmt.columnTextAlloc(gpa, 2); return .{ .name = name, .rtype = rtype, .value = value, .ttl = ttl };
errdefer gpa.free(value);
// The DDL's CHECK constraint makes the decode total for any row nxdns
// wrote; `error.Mismatch` covers a row that something else wrote.
const rtype = model.RecordType.fromDb(stmt.columnText(1)) orelse return error.Mismatch;
const ttl = std.math.cast(u32, stmt.columnInt(3)) orelse return error.Mismatch;
try out.append(gpa, .{ .name = name, .rtype = rtype, .value = value, .ttl = ttl });
}
return out;
} }
pub fn freeLocalRecords(gpa: Allocator, items: []const model.LocalRecord) void { pub fn freeLocalRecords(gpa: Allocator, items: []const model.LocalRecord) void {
for (items) |item| { crud.freeRows(model.LocalRecord, gpa, items);
gpa.free(item.name);
gpa.free(item.value);
}
} }
const insert_local_record_sql = const insert_local_record_sql =
@@ -83,28 +72,25 @@ pub fn countLocalRecords(database: *db.Db) db.Error!i64 {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub fn listForwardZones(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ForwardZone) { pub fn listForwardZones(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ForwardZone) {
var stmt = try database.prepare("SELECT zone, resolver FROM forward_zones ORDER BY zone"); return crud.listRows(
defer stmt.deinit(); model.ForwardZone,
database,
gpa,
"SELECT zone, resolver FROM forward_zones ORDER BY zone",
readForwardZone,
);
}
var out: std.ArrayList(model.ForwardZone) = .empty; fn readForwardZone(stmt: *db.Stmt, gpa: Allocator) db.Error!model.ForwardZone {
errdefer out.deinit(gpa); const zone = try stmt.columnTextAlloc(gpa, 0);
errdefer freeForwardZones(gpa, out.items); errdefer gpa.free(zone);
const resolver = try stmt.columnTextAlloc(gpa, 1);
while (try stmt.step()) { errdefer gpa.free(resolver);
const zone = try stmt.columnTextAlloc(gpa, 0); return .{ .zone = zone, .resolver = resolver };
errdefer gpa.free(zone);
const resolver = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(resolver);
try out.append(gpa, .{ .zone = zone, .resolver = resolver });
}
return out;
} }
pub fn freeForwardZones(gpa: Allocator, items: []const model.ForwardZone) void { pub fn freeForwardZones(gpa: Allocator, items: []const model.ForwardZone) void {
for (items) |item| { crud.freeRows(model.ForwardZone, gpa, items);
gpa.free(item.zone);
gpa.free(item.resolver);
}
} }
pub fn insertForwardZone(database: *db.Db, item: model.ForwardZone, ctx: InsertContext) db.Error!void { pub fn insertForwardZone(database: *db.Db, item: model.ForwardZone, ctx: InsertContext) db.Error!void {
@@ -145,28 +131,15 @@ const list_local_record_rows_sql =
/// Same order as `listLocalRecords`; every string is a heap copy owned by `gpa`. /// 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) { pub fn listLocalRecordRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(LocalRecordRow) {
var stmt = try database.prepare(list_local_record_rows_sql); return crud.listRows(LocalRecordRow, database, gpa, list_local_record_rows_sql, readLocalRecordRow);
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 { pub fn freeLocalRecordRow(gpa: Allocator, row: LocalRecordRow) void {
gpa.free(row.name); crud.freeRow(LocalRecordRow, gpa, row);
gpa.free(row.value);
} }
pub fn freeLocalRecordRows(gpa: Allocator, items: []const LocalRecordRow) void { pub fn freeLocalRecordRows(gpa: Allocator, items: []const LocalRecordRow) void {
for (items) |item| freeLocalRecordRow(gpa, item); crud.freeRows(LocalRecordRow, gpa, items);
} }
pub fn getLocalRecord(database: *db.Db, gpa: Allocator, id: i64) db.Error!?LocalRecordRow { pub fn getLocalRecord(database: *db.Db, gpa: Allocator, id: i64) db.Error!?LocalRecordRow {
@@ -232,28 +205,21 @@ pub const ForwardZoneRow = struct { id: i64, zone: []const u8, resolver: []const
/// Same order as `listForwardZones`; every string is a heap copy owned by `gpa`. /// 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) { 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"); return crud.listRows(
defer stmt.deinit(); ForwardZoneRow,
database,
var out: std.ArrayList(ForwardZoneRow) = .empty; gpa,
errdefer out.deinit(gpa); "SELECT id, zone, resolver FROM forward_zones ORDER BY zone",
errdefer freeForwardZoneRows(gpa, out.items); readForwardZoneRow,
);
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 { pub fn freeForwardZoneRow(gpa: Allocator, row: ForwardZoneRow) void {
gpa.free(row.zone); crud.freeRow(ForwardZoneRow, gpa, row);
gpa.free(row.resolver);
} }
pub fn freeForwardZoneRows(gpa: Allocator, items: []const ForwardZoneRow) void { pub fn freeForwardZoneRows(gpa: Allocator, items: []const ForwardZoneRow) void {
for (items) |item| freeForwardZoneRow(gpa, item); crud.freeRows(ForwardZoneRow, gpa, items);
} }
pub fn getForwardZone(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ForwardZoneRow { pub fn getForwardZone(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ForwardZoneRow {
+16 -40
View File
@@ -37,34 +37,23 @@ const list_sql =
/// Every string in the result is a heap copy owned by `gpa`. /// Every string in the result is a heap copy owned by `gpa`.
pub fn listRules(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Rule) { pub fn listRules(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Rule) {
var stmt = try database.prepare(list_sql); return crud.listRows(model.Rule, database, gpa, list_sql, readRule);
defer stmt.deinit(); }
var out: std.ArrayList(model.Rule) = .empty; fn readRule(stmt: *db.Stmt, gpa: Allocator) db.Error!model.Rule {
// `errdefer`s run in reverse: the free pass is declared last so it runs // The DDL's CHECK constraints make both decodes total for any row nxdns
// before the backing array is released. // wrote; `error.Mismatch` covers a row that something else wrote.
errdefer out.deinit(gpa); const kind = model.RuleKind.fromDb(stmt.columnText(2)) orelse return error.Mismatch;
errdefer freeRules(gpa, out.items); const action = model.RuleAction.fromDb(stmt.columnText(3)) orelse return error.Mismatch;
const group = try stmt.columnTextAlloc(gpa, 0);
while (try stmt.step()) { errdefer gpa.free(group);
const group = try stmt.columnTextAlloc(gpa, 0); const pattern = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(group); errdefer gpa.free(pattern);
const pattern = try stmt.columnTextAlloc(gpa, 1); return .{ .group = group, .pattern = pattern, .kind = kind, .action = action };
errdefer gpa.free(pattern);
// 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(2)) orelse return error.Mismatch;
const action = model.RuleAction.fromDb(stmt.columnText(3)) orelse return error.Mismatch;
try out.append(gpa, .{ .group = group, .pattern = pattern, .kind = kind, .action = action });
}
return out;
} }
pub fn freeRules(gpa: Allocator, items: []const model.Rule) void { pub fn freeRules(gpa: Allocator, items: []const model.Rule) void {
for (items) |item| { crud.freeRows(model.Rule, gpa, items);
gpa.free(item.group);
gpa.free(item.pattern);
}
} }
const insert_sql = const insert_sql =
@@ -132,28 +121,15 @@ const get_rule_sql =
/// Same order as `listRules`; every string is a heap copy owned by `gpa`. /// 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) { pub fn listRuleRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(RuleRow) {
var stmt = try database.prepare(list_rule_rows_sql); return crud.listRows(RuleRow, database, gpa, list_rule_rows_sql, readRuleRow);
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 { pub fn freeRuleRow(gpa: Allocator, row: RuleRow) void {
gpa.free(row.group); crud.freeRow(RuleRow, gpa, row);
gpa.free(row.pattern);
} }
pub fn freeRuleRows(gpa: Allocator, items: []const RuleRow) void { pub fn freeRuleRows(gpa: Allocator, items: []const RuleRow) void {
for (items) |item| freeRuleRow(gpa, item); crud.freeRows(RuleRow, gpa, items);
} }
pub fn getRule(database: *db.Db, gpa: Allocator, id: i64) db.Error!?RuleRow { pub fn getRule(database: *db.Db, gpa: Allocator, id: i64) db.Error!?RuleRow {
+16 -20
View File
@@ -15,38 +15,34 @@ 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;
/// Both strings of every pair are heap copies owned by `gpa`. /// Both strings of every pair are heap copies owned by `gpa`.
pub fn listSettings(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.SettingPair) { pub fn listSettings(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.SettingPair) {
var stmt = try database.prepare("SELECT key, value FROM settings ORDER BY key"); return crud.listRows(
defer stmt.deinit(); model.SettingPair,
database,
gpa,
"SELECT key, value FROM settings ORDER BY key",
readSetting,
);
}
var out: std.ArrayList(model.SettingPair) = .empty; fn readSetting(stmt: *db.Stmt, gpa: Allocator) db.Error!model.SettingPair {
// `errdefer`s run in reverse: the free pass is declared last so it runs const key = try stmt.columnTextAlloc(gpa, 0);
// before the backing array is released. errdefer gpa.free(key);
errdefer out.deinit(gpa); const value = try stmt.columnTextAlloc(gpa, 1);
errdefer freeSettings(gpa, out.items); errdefer gpa.free(value);
return .{ .key = key, .value = value };
while (try stmt.step()) {
const key = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(key);
const value = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(value);
try out.append(gpa, .{ .key = key, .value = value });
}
return out;
} }
/// Only for lists `listSettings` produced. `model.toSettings` builds pairs whose /// Only for lists `listSettings` produced. `model.toSettings` builds pairs whose
/// `key` is a comptime string and must never be freed; that list is the caller's /// `key` is a comptime string and must never be freed; that list is the caller's
/// to release, field by field. /// to release, field by field.
pub fn freeSettings(gpa: Allocator, items: []const model.SettingPair) void { pub fn freeSettings(gpa: Allocator, items: []const model.SettingPair) void {
for (items) |item| { crud.freeRows(model.SettingPair, gpa, items);
gpa.free(item.key);
gpa.free(item.value);
}
} }
pub fn insertSetting(database: *db.Db, item: model.SettingPair, ctx: InsertContext) db.Error!void { pub fn insertSetting(database: *db.Db, item: model.SettingPair, ctx: InsertContext) db.Error!void {
+24 -44
View File
@@ -25,35 +25,24 @@ const list_sql =
/// Every string in the result is a heap copy owned by `gpa`. /// Every string in the result is a heap copy owned by `gpa`.
pub fn listBlocklistSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.BlocklistSource) { pub fn listBlocklistSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.BlocklistSource) {
var stmt = try database.prepare(list_sql); return crud.listRows(model.BlocklistSource, database, gpa, list_sql, readBlocklistSource);
defer stmt.deinit(); }
var out: std.ArrayList(model.BlocklistSource) = .empty; fn readBlocklistSource(stmt: *db.Stmt, gpa: Allocator) db.Error!model.BlocklistSource {
// `errdefer`s run in reverse: the free pass is declared last so it runs const url = try stmt.columnTextAlloc(gpa, 0);
// before the backing array is released. errdefer gpa.free(url);
errdefer out.deinit(gpa); const name = try stmt.columnTextAlloc(gpa, 1);
errdefer freeBlocklistSources(gpa, out.items); errdefer gpa.free(name);
return .{
while (try stmt.step()) { .url = url,
const url = try stmt.columnTextAlloc(gpa, 0); .name = name,
errdefer gpa.free(url); .enabled = stmt.columnBool(2),
const name = try stmt.columnTextAlloc(gpa, 1); .is_suggested = stmt.columnBool(3),
errdefer gpa.free(name); };
try out.append(gpa, .{
.url = url,
.name = name,
.enabled = stmt.columnBool(2),
.is_suggested = stmt.columnBool(3),
});
}
return out;
} }
pub fn freeBlocklistSources(gpa: Allocator, items: []const model.BlocklistSource) void { pub fn freeBlocklistSources(gpa: Allocator, items: []const model.BlocklistSource) void {
for (items) |item| { crud.freeRows(model.BlocklistSource, gpa, items);
gpa.free(item.url);
gpa.free(item.name);
}
} }
const insert_sql = const insert_sql =
@@ -125,21 +114,7 @@ const list_rows_sql = row_columns_sql ++ " ORDER BY 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.
pub fn listSourceRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(SourceRow) { pub fn listSourceRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(SourceRow) {
var stmt = try database.prepare(list_rows_sql); return crud.listRows(SourceRow, database, gpa, list_rows_sql, readSourceRow);
defer stmt.deinit();
var out: std.ArrayList(SourceRow) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeSourceRows(gpa, out.items);
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 { fn readSourceRow(stmt: *db.Stmt, gpa: Allocator) db.Error!SourceRow {
@@ -163,14 +138,13 @@ fn readSourceRow(stmt: *db.Stmt, gpa: Allocator) db.Error!SourceRow {
}; };
} }
/// Frees `url`, `name` and the `checksum` payload when it is not null.
pub fn freeSourceRow(gpa: Allocator, row: SourceRow) void { pub fn freeSourceRow(gpa: Allocator, row: SourceRow) void {
gpa.free(row.url); crud.freeRow(SourceRow, gpa, row);
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| freeSourceRow(gpa, item); crud.freeRows(SourceRow, gpa, items);
} }
const update_stats_sql = const update_stats_sql =
@@ -438,6 +412,12 @@ fn listSourceRowsUnderFailure(gpa: Allocator) !void {
var rows = try listSourceRows(&database, gpa); var rows = try listSourceRows(&database, gpa);
defer rows.deinit(gpa); defer rows.deinit(gpa);
defer freeSourceRows(gpa, rows.items); defer freeSourceRows(gpa, rows.items);
// `checksum` is the one allocated optional in this directory. Without a
// non-null one in the result the injection never reaches its allocation and
// this test stops covering the shape it exists for. Row id 1 sorts last:
// `seedSources` inserts `c.example` first and the list orders by url.
try testing.expect(rows.items[2].checksum != null);
} }
test "listSourceRows is leak-safe under allocation failure" { test "listSourceRows is leak-safe under allocation failure" {
+22 -42
View File
@@ -21,38 +21,31 @@ const InsertContext = context.InsertContext;
/// Every string in the result is a heap copy owned by `gpa`. /// Every string in the result is a heap copy owned by `gpa`.
pub fn listUpstreams(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.UpstreamServer) { pub fn listUpstreams(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.UpstreamServer) {
var stmt = try database.prepare( return crud.listRows(
model.UpstreamServer,
database,
gpa,
"SELECT url, priority, enabled, tls_name FROM upstreams ORDER BY priority, url", "SELECT url, priority, enabled, tls_name FROM upstreams ORDER BY priority, url",
readUpstream,
); );
defer stmt.deinit(); }
var out: std.ArrayList(model.UpstreamServer) = .empty; fn readUpstream(stmt: *db.Stmt, gpa: Allocator) db.Error!model.UpstreamServer {
// `errdefer`s run in reverse: the free pass is declared last so it runs const priority = std.math.cast(i32, stmt.columnInt(1)) orelse return error.Mismatch;
// before the backing array is released. const url = try stmt.columnTextAlloc(gpa, 0);
errdefer out.deinit(gpa); errdefer gpa.free(url);
errdefer freeUpstreams(gpa, out.items); const tls_name = try stmt.columnTextAlloc(gpa, 3);
errdefer gpa.free(tls_name);
while (try stmt.step()) { return .{
const url = try stmt.columnTextAlloc(gpa, 0); .url = url,
errdefer gpa.free(url); .priority = priority,
const priority = std.math.cast(i32, stmt.columnInt(1)) orelse return error.Mismatch; .enabled = stmt.columnBool(2),
const tls_name = try stmt.columnTextAlloc(gpa, 3); .tls_name = tls_name,
errdefer gpa.free(tls_name); };
try out.append(gpa, .{
.url = url,
.priority = priority,
.enabled = stmt.columnBool(2),
.tls_name = tls_name,
});
}
return out;
} }
pub fn freeUpstreams(gpa: Allocator, items: []const model.UpstreamServer) void { pub fn freeUpstreams(gpa: Allocator, items: []const model.UpstreamServer) void {
for (items) |item| { crud.freeRows(model.UpstreamServer, gpa, items);
gpa.free(item.url);
gpa.free(item.tls_name);
}
} }
pub fn insertUpstream(database: *db.Db, item: model.UpstreamServer, ctx: InsertContext) db.Error!void { pub fn insertUpstream(database: *db.Db, item: model.UpstreamServer, ctx: InsertContext) db.Error!void {
@@ -101,28 +94,15 @@ const get_upstream_sql =
/// Same order as `listUpstreams`; every string is a heap copy owned by `gpa`. /// 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) { pub fn listUpstreamRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(UpstreamRow) {
var stmt = try database.prepare(list_upstream_rows_sql); return crud.listRows(UpstreamRow, database, gpa, list_upstream_rows_sql, readUpstreamRow);
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 { pub fn freeUpstreamRow(gpa: Allocator, row: UpstreamRow) void {
gpa.free(row.url); crud.freeRow(UpstreamRow, gpa, row);
gpa.free(row.tls_name);
} }
pub fn freeUpstreamRows(gpa: Allocator, items: []const UpstreamRow) void { pub fn freeUpstreamRows(gpa: Allocator, items: []const UpstreamRow) void {
for (items) |item| freeUpstreamRow(gpa, item); crud.freeRows(UpstreamRow, gpa, items);
} }
pub fn getUpstream(database: *db.Db, gpa: Allocator, id: i64) db.Error!?UpstreamRow { pub fn getUpstream(database: *db.Db, gpa: Allocator, id: i64) db.Error!?UpstreamRow {
+1
View File
@@ -24,6 +24,7 @@ comptime {
_ = @import("upstream/pool.zig"); _ = @import("upstream/pool.zig");
_ = @import("upstream/dot_client.zig"); _ = @import("upstream/dot_client.zig");
_ = @import("upstream/dot_client_live_test.zig"); _ = @import("upstream/dot_client_live_test.zig");
_ = @import("server/listener.zig");
_ = @import("server/handler.zig"); _ = @import("server/handler.zig");
_ = @import("server/udp_server.zig"); _ = @import("server/udp_server.zig");
_ = @import("server/tcp_server.zig"); _ = @import("server/tcp_server.zig");
+187 -4
View File
@@ -20,6 +20,13 @@ pub const media_type = "application/dns-message";
pub const min_request_buf = 512; pub const min_request_buf = 512;
pub const min_transfer_buf = 1024; pub const min_transfer_buf = 1024;
/// What `nxdns run` gives every DoH client, and what `nxdns check` probes an
/// upstream with. They live here rather than beside either caller because a
/// probe that used a different buffer than the running server would answer a
/// question nobody asked.
pub const default_request_buf_len = 1024;
pub const default_transfer_buf_len = 4096;
pub const DohClient = struct { pub const DohClient = struct {
/// Caller-owned; shared across endpoints, pools connections. /// Caller-owned; shared across endpoints, pools connections.
http: *std.http.Client, http: *std.http.Client,
@@ -110,11 +117,11 @@ pub const DohClient = struct {
defer req.deinit(); defer req.deinit();
req.sendBodyComplete(self.request_buf[0..query.len]) catch |err| req.sendBodyComplete(self.request_buf[0..query.len]) catch |err|
return mapError(err, .send); return mapError(sendCause(&req, err), .send);
// An empty redirect buffer is legal under `.not_allowed`: a redirect // An empty redirect buffer is legal under `.not_allowed`: a redirect
// is an error before the location is ever read. // is an error before the location is ever read.
var resp = req.receiveHead(&.{}) catch |err| return mapError(err, .receive); var resp = req.receiveHead(&.{}) catch |err| return mapError(headCause(&req, err), .receive);
if (resp.head.status != .ok) return error.HttpStatus; if (resp.head.status != .ok) return error.HttpStatus;
// `head.content_type` points into memory that `resp.reader` invalidates, // `head.content_type` points into memory that `resp.reader` invalidates,
@@ -129,7 +136,7 @@ pub const DohClient = struct {
var ended = false; var ended = false;
while (len < response_buf.len) { while (len < response_buf.len) {
const n = body.readSliceShort(response_buf[len..]) catch |err| const n = body.readSliceShort(response_buf[len..]) catch |err|
return mapError(err, .receive); return mapError(bodyCause(&resp, err), .receive);
len += n; len += n;
if (n == 0) { if (n == 0) {
ended = true; ended = true;
@@ -140,7 +147,8 @@ pub const DohClient = struct {
// `response_buf` filled exactly. One more read separates a message // `response_buf` filled exactly. One more read separates a message
// that fits from one that was cut off. // that fits from one that was cut off.
var probe: [1]u8 = undefined; var probe: [1]u8 = undefined;
const n = body.readSliceShort(&probe) catch |err| return mapError(err, .receive); const n = body.readSliceShort(&probe) catch |err|
return mapError(bodyCause(&resp, err), .receive);
if (n != 0) return error.ResponseTooLarge; if (n != 0) return error.ResponseTooLarge;
} }
@@ -166,6 +174,48 @@ fn mapError(err: anyerror, phase: Phase) transport.ExchangeError {
}; };
} }
const Connection = std.http.Client.Connection;
const Request = std.http.Client.Request;
const Response = std.http.Client.Response;
/// `std.Io.Writer` collapses every send failure to `error.WriteFailed` and
/// stashes the cause on the connection's socket writer. Unwrapping it is what
/// keeps `error.Canceled` and the local resource errors out of the peer fault
/// group, exactly as `concreteWrite` does for DoT.
fn sendCause(req: *const Request, err: anyerror) anyerror {
if (err != error.WriteFailed) return err;
const connection = req.connection orelse return err;
return connection.stream_writer.err orelse err;
}
/// `receiveHead` collapses a transport failure to `error.ReadFailed` and names
/// `Connection.getReadError` as the accessor for the concrete cause.
fn headCause(req: *const Request, err: anyerror) anyerror {
if (err != error.ReadFailed) return err;
const connection = req.connection orelse return err;
return readCause(connection, err);
}
/// A body read reports two different kinds of failure through the same
/// `error.ReadFailed`. An HTTP framing fault lands on the response, and only a
/// read that never reached the framing leaves the connection's cause, so the
/// response is consulted first.
fn bodyCause(resp: *const Response, err: anyerror) anyerror {
if (err != error.ReadFailed) return err;
if (resp.bodyErr()) |cause| return cause;
const connection = resp.request.connection orelse return err;
return readCause(connection, err);
}
/// `Connection.getReadError` reads the socket reader's stashed cause with `.?`.
/// On a plain connection that is its only source, so calling it with nothing
/// stashed would panic rather than return null; the guard keeps this unwrap
/// total on the path this client can reach without TLS.
fn readCause(connection: *const Connection, err: anyerror) anyerror {
if (connection.protocol == .plain and connection.stream_reader.err == null) return err;
return connection.getReadError() orelse err;
}
/// RFC 8484 §6: the response media type is `application/dns-message`. The /// RFC 8484 §6: the response media type is `application/dns-message`. The
/// header may carry parameters (`; charset=…`) and the type is case-insensitive /// header may carry parameters (`; charset=…`) and the type is case-insensitive
/// per RFC 9110 §8.3.1. /// per RFC 9110 §8.3.1.
@@ -269,3 +319,136 @@ test "mapError maps remaining errors by phase" {
try testing.expectEqual(error.ReceiveFailed, mapError(error.ReadFailed, .receive)); try testing.expectEqual(error.ReceiveFailed, mapError(error.ReadFailed, .receive));
try testing.expectEqual(error.ReceiveFailed, mapError(error.HttpHeadersInvalid, .receive)); try testing.expectEqual(error.ReceiveFailed, mapError(error.HttpHeadersInvalid, .receive));
} }
/// Only the fields the unwrap helpers read are set. The rest of a `Connection`
/// is two buffered streams, a host name and a pool node, none of which the
/// helpers touch.
///
/// `.plain` on purpose: `Connection.getReadError` reaches a TLS connection's
/// stashed cause through `@fieldParentPtr`, which on a stub would read memory
/// that was never a `Tls`. What the test is about — that the accessor is
/// consulted at all — is the same on both protocols.
fn stubConnection(
read_err: ?std.Io.net.Stream.Reader.Error,
write_err: ?std.Io.net.Stream.Writer.Error,
) Connection {
var connection: Connection = undefined;
connection.protocol = .plain;
connection.stream_reader.err = read_err;
connection.stream_writer.err = write_err;
return connection;
}
fn stubRequest(connection: *Connection, body_err: ?std.http.Reader.BodyError) Request {
var req: Request = undefined;
req.connection = connection;
req.reader.body_err = body_err;
return req;
}
test "the send unwrap keeps a cancelled write out of the peer fault group" {
var connection = stubConnection(null, error.Canceled);
var req = stubRequest(&connection, null);
const mapped = mapError(sendCause(&req, error.WriteFailed), .send);
try testing.expectEqual(transport.ExchangeError.Canceled, mapped);
try testing.expectEqual(transport.Group.cancellation, transport.group(mapped));
}
test "the send unwrap keeps a local resource write failure out of the peer fault group" {
var connection = stubConnection(null, error.SystemResources);
var req = stubRequest(&connection, null);
const mapped = mapError(sendCause(&req, error.WriteFailed), .send);
try testing.expectEqual(transport.ExchangeError.SystemResources, mapped);
try testing.expectEqual(transport.Group.local_resource, transport.group(mapped));
}
test "the send unwrap reports a peer side cause as a send fault" {
var connection = stubConnection(null, error.ConnectionResetByPeer);
var req = stubRequest(&connection, null);
try testing.expectEqual(
transport.ExchangeError.SendFailed,
mapError(sendCause(&req, error.WriteFailed), .send),
);
}
test "the head unwrap keeps a local resource read failure out of the peer fault group" {
var connection = stubConnection(error.SystemResources, null);
var req = stubRequest(&connection, null);
const mapped = mapError(headCause(&req, error.ReadFailed), .receive);
try testing.expectEqual(transport.ExchangeError.SystemResources, mapped);
try testing.expectEqual(transport.Group.local_resource, transport.group(mapped));
var canceled = stubConnection(error.Canceled, null);
var canceled_req = stubRequest(&canceled, null);
try testing.expectEqual(
transport.ExchangeError.Canceled,
mapError(headCause(&canceled_req, error.ReadFailed), .receive),
);
}
test "the head unwrap reports a peer side cause as a receive fault" {
var connection = stubConnection(error.ConnectionResetByPeer, null);
var req = stubRequest(&connection, null);
try testing.expectEqual(
transport.ExchangeError.ReceiveFailed,
mapError(headCause(&req, error.ReadFailed), .receive),
);
}
test "the body unwrap keeps a cancelled read out of the peer fault group" {
var connection = stubConnection(error.Canceled, null);
var req = stubRequest(&connection, null);
const resp: Response = .{ .request = &req, .head = undefined };
const mapped = mapError(bodyCause(&resp, error.ReadFailed), .receive);
try testing.expectEqual(transport.ExchangeError.Canceled, mapped);
try testing.expectEqual(transport.Group.cancellation, transport.group(mapped));
}
test "the body unwrap prefers an http framing fault over the connection" {
// A truncated chunk is the peer's doing and the connection carries no
// cause at all, so reading the connection first would report the wrong
// thing on the one path where both could be set.
var connection = stubConnection(null, null);
var req = stubRequest(&connection, error.HttpChunkTruncated);
const resp: Response = .{ .request = &req, .head = undefined };
try testing.expectEqual(error.HttpChunkTruncated, bodyCause(&resp, error.ReadFailed));
try testing.expectEqual(
transport.ExchangeError.ReceiveFailed,
mapError(bodyCause(&resp, error.ReadFailed), .receive),
);
}
test "the unwraps report the collapsed error when no cause was stored" {
var connection = stubConnection(null, null);
var req = stubRequest(&connection, null);
const resp: Response = .{ .request = &req, .head = undefined };
try testing.expectEqual(error.WriteFailed, sendCause(&req, error.WriteFailed));
try testing.expectEqual(error.ReadFailed, headCause(&req, error.ReadFailed));
try testing.expectEqual(error.ReadFailed, bodyCause(&resp, error.ReadFailed));
try testing.expectEqual(
transport.ExchangeError.SendFailed,
mapError(sendCause(&req, error.WriteFailed), .send),
);
try testing.expectEqual(
transport.ExchangeError.ReceiveFailed,
mapError(bodyCause(&resp, error.ReadFailed), .receive),
);
}
test "the unwraps pass a non-collapsed error through untouched" {
// A stashed cause belongs to `error.ReadFailed` / `error.WriteFailed`. Any
// other error already names itself, so the stash must not be read over it.
var connection = stubConnection(error.Canceled, error.Canceled);
var req = stubRequest(&connection, error.HttpChunkInvalid);
const resp: Response = .{ .request = &req, .head = undefined };
try testing.expectEqual(error.EndOfStream, sendCause(&req, error.EndOfStream));
try testing.expectEqual(error.HttpHeadersInvalid, headCause(&req, error.HttpHeadersInvalid));
try testing.expectEqual(error.EndOfStream, bodyCause(&resp, error.EndOfStream));
try testing.expectEqual(
transport.ExchangeError.ReceiveFailed,
mapError(headCause(&req, error.HttpHeadersInvalid), .receive),
);
}
+19 -39
View File
@@ -179,9 +179,9 @@ pub const DotClient = struct {
var stream = address.connect(io, .{ .mode = .stream }) catch |err| { var stream = address.connect(io, .{ .mode = .stream }) catch |err| {
log.debug("{f}", .{self.diagnose(.{ .connect_failed = err })}); log.debug("{f}", .{self.diagnose(.{ .connect_failed = err })});
return mapPhase(err, error.ConnectFailed); return transport.mapPhase(err, error.ConnectFailed);
}; };
defer closeStream(io, &stream); defer transport.closeBlocked(io, &stream);
// `TlsStream` is pinned: it holds its reader and writer by value and the // `TlsStream` is pinned: it holds its reader and writer by value and the
// TLS client points at them, so it must not move after `init`. // TLS client points at them, so it must not move after `init`.
@@ -205,9 +205,9 @@ pub const DotClient = struct {
.verify_name = self.verify_name, .verify_name = self.verify_name,
.cause = cause, .cause = cause,
} })}); } })});
return mapPhase(cause, error.TlsFailed); return transport.mapPhase(cause, error.TlsFailed);
}; };
defer closeTls(io, &tls_stream); defer transport.closeBlocked(io, &tls_stream);
const writer = tls_stream.writer(); const writer = tls_stream.writer();
const prefix = transport.framePrefix(@intCast(query.len)); const prefix = transport.framePrefix(@intCast(query.len));
@@ -241,7 +241,7 @@ pub const DotClient = struct {
/// cancellation into `error.CertificateBundleLoadFailure`. That name cannot /// cancellation into `error.CertificateBundleLoadFailure`. That name cannot
/// tell an `error.OutOfMemory` from a corrupt PEM file, and the first is a /// tell an `error.OutOfMemory` from a corrupt PEM file, and the first is a
/// local resource failure that must not count against the upstream's /// local resource failure that must not count against the upstream's
/// health. Scanning here keeps the concrete error for `mapPhase`. /// health. Scanning here keeps the concrete error for `transport.mapPhase`.
fn ensureBundle(self: *DotClient, io: std.Io) transport.ExchangeError!void { fn ensureBundle(self: *DotClient, io: std.Io) transport.ExchangeError!void {
{ {
try self.bundle_lock.lockShared(io); try self.bundle_lock.lockShared(io);
@@ -259,31 +259,11 @@ pub const DotClient = struct {
self.bundle.deinit(self.gpa); self.bundle.deinit(self.gpa);
self.bundle.* = .empty; self.bundle.* = .empty;
log.warn("{f}", .{self.diagnose(.{ .bundle_load_failed = err })}); log.warn("{f}", .{self.diagnose(.{ .bundle_load_failed = err })});
return mapPhase(err, error.TlsFailed); return transport.mapPhase(err, error.TlsFailed);
}; };
} }
}; };
/// The pool cancels this task when the attempt budget expires. The next
/// cancelable `Io` call in the `defer` chain would then return `error.Canceled`
/// and skip the close, leaking the socket, so the close runs with cancellation
/// blocked.
fn closeStream(io: std.Io, stream: *net.Stream) void {
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
stream.close(io);
}
fn closeTls(io: std.Io, stream: *tls_client.TlsStream) void {
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
stream.close();
}
fn mapPhase(err: anyerror, phase: transport.PeerFault) transport.ExchangeError {
return transport.mapLocal(err) orelse phase;
}
/// The handshake reads and writes through the socket reader and writer, so a /// The handshake reads and writes through the socket reader and writer, so a
/// cancelled or resource-starved handshake surfaces as `error.ReadFailed` / /// cancelled or resource-starved handshake surfaces as `error.ReadFailed` /
/// `error.WriteFailed` with the cause stashed on those two. Without this, /// `error.WriteFailed` with the cause stashed on those two. Without this,
@@ -318,11 +298,11 @@ fn concreteWrite(stream: *tls_client.TlsStream, err: anyerror) anyerror {
} }
fn sendFailure(stream: *tls_client.TlsStream, err: anyerror) transport.ExchangeError { fn sendFailure(stream: *tls_client.TlsStream, err: anyerror) transport.ExchangeError {
return mapPhase(concreteWrite(stream, err), error.SendFailed); return transport.mapPhase(concreteWrite(stream, err), error.SendFailed);
} }
fn receiveFailure(stream: *tls_client.TlsStream, err: anyerror) transport.ExchangeError { fn receiveFailure(stream: *tls_client.TlsStream, err: anyerror) transport.ExchangeError {
return mapPhase(concreteRead(stream, err), error.ReceiveFailed); return transport.mapPhase(concreteRead(stream, err), error.ReceiveFailed);
} }
const testing = std.testing; const testing = std.testing;
@@ -460,14 +440,14 @@ fn stubStream(
test "the handshake unwrap keeps a cancelled read out of the peer fault group" { test "the handshake unwrap keeps a cancelled read out of the peer fault group" {
var stream = stubStream(error.Canceled, null, null); var stream = stubStream(error.Canceled, null, null);
const mapped = mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed); const mapped = transport.mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed);
try testing.expectEqual(transport.ExchangeError.Canceled, mapped); try testing.expectEqual(transport.ExchangeError.Canceled, mapped);
try testing.expectEqual(transport.Group.cancellation, transport.group(mapped)); try testing.expectEqual(transport.Group.cancellation, transport.group(mapped));
} }
test "the handshake unwrap keeps a local resource write failure out of the peer fault group" { test "the handshake unwrap keeps a local resource write failure out of the peer fault group" {
var stream = stubStream(null, error.SystemResources, null); var stream = stubStream(null, error.SystemResources, null);
const mapped = mapPhase(concreteHandshake(&stream, error.WriteFailed), error.TlsFailed); const mapped = transport.mapPhase(concreteHandshake(&stream, error.WriteFailed), error.TlsFailed);
try testing.expectEqual(transport.ExchangeError.SystemResources, mapped); try testing.expectEqual(transport.ExchangeError.SystemResources, mapped);
try testing.expectEqual(transport.Group.local_resource, transport.group(mapped)); try testing.expectEqual(transport.Group.local_resource, transport.group(mapped));
} }
@@ -476,13 +456,13 @@ test "the handshake unwrap reports a peer side cause as a TLS fault" {
var reset = stubStream(error.ConnectionResetByPeer, null, null); var reset = stubStream(error.ConnectionResetByPeer, null, null);
try testing.expectEqual( try testing.expectEqual(
transport.ExchangeError.TlsFailed, transport.ExchangeError.TlsFailed,
mapPhase(concreteHandshake(&reset, error.ReadFailed), error.TlsFailed), transport.mapPhase(concreteHandshake(&reset, error.ReadFailed), error.TlsFailed),
); );
var refused = stubStream(null, error.ConnectionRefused, null); var refused = stubStream(null, error.ConnectionRefused, null);
try testing.expectEqual( try testing.expectEqual(
transport.ExchangeError.TlsFailed, transport.ExchangeError.TlsFailed,
mapPhase(concreteHandshake(&refused, error.WriteFailed), error.TlsFailed), transport.mapPhase(concreteHandshake(&refused, error.WriteFailed), error.TlsFailed),
); );
} }
@@ -492,7 +472,7 @@ test "the handshake unwrap reports a TLS fault when no cause was stored" {
try testing.expectEqual(error.WriteFailed, concreteHandshake(&stream, error.WriteFailed)); try testing.expectEqual(error.WriteFailed, concreteHandshake(&stream, error.WriteFailed));
try testing.expectEqual( try testing.expectEqual(
transport.ExchangeError.TlsFailed, transport.ExchangeError.TlsFailed,
mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed), transport.mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed),
); );
} }
@@ -505,11 +485,11 @@ test "the handshake unwrap passes other errors through untouched" {
try testing.expectEqual(error.Canceled, concreteHandshake(&stream, error.Canceled)); try testing.expectEqual(error.Canceled, concreteHandshake(&stream, error.Canceled));
try testing.expectEqual( try testing.expectEqual(
transport.ExchangeError.TlsFailed, transport.ExchangeError.TlsFailed,
mapPhase(concreteHandshake(&stream, error.CertificateExpired), error.TlsFailed), transport.mapPhase(concreteHandshake(&stream, error.CertificateExpired), error.TlsFailed),
); );
try testing.expectEqual( try testing.expectEqual(
transport.ExchangeError.Canceled, transport.ExchangeError.Canceled,
mapPhase(concreteHandshake(&stream, error.Canceled), error.TlsFailed), transport.mapPhase(concreteHandshake(&stream, error.Canceled), error.TlsFailed),
); );
} }
@@ -547,24 +527,24 @@ test "a CA bundle scan failure keeps local resource errors out of the peer fault
for (local) |err| { for (local) |err| {
try testing.expectEqual( try testing.expectEqual(
transport.Group.local_resource, transport.Group.local_resource,
transport.group(mapPhase(err, error.TlsFailed)), transport.group(transport.mapPhase(err, error.TlsFailed)),
); );
} }
try testing.expectEqual( try testing.expectEqual(
transport.ExchangeError.Canceled, transport.ExchangeError.Canceled,
mapPhase(error.Canceled, error.TlsFailed), transport.mapPhase(error.Canceled, error.TlsFailed),
); );
// A missing or corrupt bundle is not this process running out of anything, // A missing or corrupt bundle is not this process running out of anything,
// so it stays a TLS fault. // so it stays a TLS fault.
try testing.expectEqual( try testing.expectEqual(
transport.ExchangeError.TlsFailed, transport.ExchangeError.TlsFailed,
mapPhase(error.FileNotFound, error.TlsFailed), transport.mapPhase(error.FileNotFound, error.TlsFailed),
); );
try testing.expectEqual( try testing.expectEqual(
transport.ExchangeError.TlsFailed, transport.ExchangeError.TlsFailed,
mapPhase(error.MissingEndCertificateMarker, error.TlsFailed), transport.mapPhase(error.MissingEndCertificateMarker, error.TlsFailed),
); );
} }
+6 -59
View File
@@ -181,28 +181,10 @@ pub const Pool = struct {
query: []const u8, query: []const u8,
response_buf: []u8, response_buf: []u8,
) transport.ExchangeError![]u8 { ) transport.ExchangeError![]u8 {
var outcomes: [2]LoopOutcome = undefined; const len = try transport.raceWithin(io, self.timeouts.total, exchangeLoopLen, .{
var race: std.Io.Select(LoopOutcome) = .init(io, &outcomes);
defer race.cancelDiscard();
race.concurrent(.loop, exchangeLoopLen, .{
self, io, query, response_buf, self, io, query, response_buf,
}) catch |err| switch (err) { });
error.ConcurrencyUnavailable => return error.SystemResources, return response_buf[0..len];
};
race.concurrent(.expiry, expire, .{ io, self.timeouts.total }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
switch (try race.await()) {
.loop => |result| return response_buf[0..try result],
.expiry => |result| {
// A canceled sleep means this whole task is being torn down,
// not that the budget ran out.
try result;
return error.Timeout;
},
}
} }
/// The two-pass failover loop, as a raceable task. It returns the reply's /// The two-pass failover loop, as a raceable task. It returns the reply's
@@ -300,9 +282,7 @@ pub const Pool = struct {
return count; return count;
} }
/// One exchange raced against the per-attempt budget. No stream read or /// One exchange raced against the per-attempt budget.
/// write in 0.16.0 takes a timeout, so the budget is a second task and the
/// loser is canceled.
fn attempt( fn attempt(
self: *Pool, self: *Pool,
io: std.Io, io: std.Io,
@@ -310,28 +290,9 @@ pub const Pool = struct {
query: []const u8, query: []const u8,
response_buf: []u8, response_buf: []u8,
) transport.ExchangeError![]u8 { ) transport.ExchangeError![]u8 {
var outcomes: [2]Outcome = undefined; return transport.raceWithin(io, self.timeouts.attempt, transport.Client.exchange, .{
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
defer race.cancelDiscard();
race.concurrent(.exchange, transport.Client.exchange, .{
entry_client, io, query, response_buf, entry_client, io, query, response_buf,
}) catch |err| switch (err) { });
error.ConcurrencyUnavailable => return error.SystemResources,
};
race.concurrent(.expiry, expire, .{ io, self.timeouts.attempt }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
switch (try race.await()) {
.exchange => |result| return result,
.expiry => |result| {
// A canceled sleep means this whole task is being torn down,
// not that the upstream is slow.
try result;
return error.Timeout;
},
}
} }
fn entryAvailable( fn entryAvailable(
@@ -367,20 +328,6 @@ pub const Pool = struct {
} }
}; };
const Outcome = union(enum) {
exchange: transport.ExchangeError![]u8,
expiry: std.Io.Cancelable!void,
};
const LoopOutcome = union(enum) {
loop: transport.ExchangeError!usize,
expiry: std.Io.Cancelable!void,
};
fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void {
return duration.sleep(io);
}
const testing = std.testing; const testing = std.testing;
/// A query for example.com A: id 0x1234, RD set, one question. /// A query for example.com A: id 0x1234, RD set, one question.
+202
View File
@@ -229,6 +229,97 @@ pub fn mapLocal(err: anyerror) ?ExchangeError {
}; };
} }
/// Names the peer fault of the phase the call site is in, unless the error is
/// one `mapLocal` claims for this process. Every transport classifies its
/// failures through this one function.
pub fn mapPhase(err: anyerror, phase: PeerFault) ExchangeError {
return mapLocal(err) orelse phase;
}
/// Closes `target` with cancellation blocked.
///
/// A transport's close runs from a `defer` chain that a lost timeout race is
/// unwinding. The next cancelable `Io` call in that chain returns
/// `error.Canceled` and skips the close, leaking the descriptor, so the close
/// swaps cancellation protection for the duration.
///
/// `net.Stream` and `net.Socket` close through an `Io`; `tls_client.TlsStream`
/// owns the one it was built with and takes none. Both shapes are accepted so
/// that one helper covers every close in the transports.
pub fn closeBlocked(io: std.Io, target: anytype) void {
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
const Target = @typeInfo(@TypeOf(target)).pointer.child;
if (@typeInfo(@TypeOf(Target.close)).@"fn".params.len == 2) {
target.close(io);
} else {
target.close();
}
}
/// The payload of `f`'s return type, which `raceWithin` requires to be
/// `ExchangeError!T`. A raced function with any other error set would let a
/// failure reach the pool without passing through `group`.
fn RacedPayload(comptime f: anytype) type {
const info = @typeInfo(@TypeOf(f));
if (info != .@"fn") @compileError("raceWithin needs a function, found " ++ @typeName(@TypeOf(f)));
const Return = info.@"fn".return_type orelse
@compileError("raceWithin needs a function with a concrete return type");
const union_info = switch (@typeInfo(Return)) {
.error_union => |u| u,
else => @compileError("raceWithin needs `ExchangeError!T`, found " ++ @typeName(Return)),
};
if (union_info.error_set != ExchangeError)
@compileError("raceWithin needs `ExchangeError!T`, found " ++ @typeName(Return));
return union_info.payload;
}
/// Runs `f(args...)` raced against `budget`, and cancels the loser.
///
/// No stream read or write in 0.16.0 takes a timeout, so a deadline is a second
/// task rather than a socket option. This is the one copy of that harness: the
/// pool races an attempt and its whole failover loop through it, and the
/// forward client races its TCP exchange.
///
/// `error.Timeout` means the budget won. A canceled sleep means the whole task
/// is being torn down rather than the budget running out, so it stays
/// `error.Canceled`. A backend that cannot start a second task is
/// `error.SystemResources`, which `group` keeps off the peer's health.
pub fn raceWithin(
io: std.Io,
budget: std.Io.Clock.Duration,
comptime f: anytype,
args: anytype,
) ExchangeError!RacedPayload(f) {
const Outcome = union(enum) {
raced: ExchangeError!RacedPayload(f),
expiry: std.Io.Cancelable!void,
};
var outcomes: [2]Outcome = undefined;
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
defer race.cancelDiscard();
race.concurrent(.raced, f, args) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
race.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
switch (try race.await()) {
.raced => |result| return result,
.expiry => |result| {
try result;
return error.Timeout;
},
}
}
fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void {
return budget.sleep(io);
}
/// A thing that sends one DNS message and returns one validated DNS message. /// A thing that sends one DNS message and returns one validated DNS message.
/// Implemented by DohClient, DotClient, Pool, and test fakes. /// Implemented by DohClient, DotClient, Pool, and test fakes.
pub const Client = struct { pub const Client = struct {
@@ -430,6 +521,117 @@ test "mapLocal folds only local and cancellation errors" {
try testing.expectEqual(@as(?ExchangeError, null), mapLocal(error.TlsInitializationFailed)); try testing.expectEqual(@as(?ExchangeError, null), mapLocal(error.TlsInitializationFailed));
} }
test "mapPhase names the phase unless the error is this process's own" {
try testing.expectEqual(ExchangeError.Canceled, mapPhase(error.Canceled, error.ReceiveFailed));
try testing.expectEqual(
ExchangeError.SystemResources,
mapPhase(error.SystemResources, error.ConnectFailed),
);
try testing.expectEqual(
ExchangeError.ConnectFailed,
mapPhase(error.ConnectionRefused, error.ConnectFailed),
);
try testing.expectEqual(
Group.local_resource,
group(mapPhase(error.OutOfMemory, error.SendFailed)),
);
}
/// Stands in for whatever the pool or the forward client races. The variants
/// are the three ways such a task ends: in time, too late, or with a failure of
/// its own.
fn racedReply(io: std.Io, delay_ms: i64, result: ExchangeError!usize) ExchangeError!usize {
if (delay_ms != 0) {
const duration: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(delay_ms), .clock = .awake };
try duration.sleep(io);
}
return result;
}
test "raceWithin returns the raced value when it finishes inside the budget" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(30), .clock = .awake };
const len = try raceWithin(io, budget, racedReply, .{ io, 0, @as(ExchangeError!usize, 7) });
try testing.expectEqual(@as(usize, 7), len);
}
test "raceWithin returns Timeout when the budget wins" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const budget: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(20), .clock = .awake };
const started = std.Io.Clock.awake.now(io);
try testing.expectError(
error.Timeout,
raceWithin(io, budget, racedReply, .{ io, 30_000, @as(ExchangeError!usize, 7) }),
);
const elapsed_ns = std.Io.Clock.awake.now(io).nanoseconds - started.nanoseconds;
// Far under the raced task's own sleep, so the budget is provably what
// ended the call rather than the task finishing on its own.
try testing.expect(elapsed_ns < @as(i96, 5) * std.time.ns_per_s);
}
test "raceWithin passes the raced task's own failure through" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(30), .clock = .awake };
// A local resource error keeps its group: the race must not turn it into
// the peer fault the budget would have produced.
const failed = raceWithin(io, budget, racedReply, .{
io, 0, @as(ExchangeError!usize, error.OutOfMemory),
});
try testing.expectError(error.OutOfMemory, failed);
try testing.expectError(
error.ConnectFailed,
raceWithin(io, budget, racedReply, .{ io, 0, @as(ExchangeError!usize, error.ConnectFailed) }),
);
}
test "closeBlocked closes a target of either close shape" {
// The two shapes the transports use: a socket or a plain stream, which
// closes through the `Io`, and a `TlsStream`, which owns the one it was
// built with. Dispatching on the wrong one is a compile error, so
// instantiating both is the check.
//
// That the close runs with cancellation blocked is not asserted here: the
// Threaded backend's `swapCancelProtection` is a no-op off one of its own
// task threads, so a unit test cannot observe the state it sets.
const WithIo = struct {
closed: bool = false,
fn close(self: *@This(), io: std.Io) void {
_ = io;
self.closed = true;
}
};
const WithoutIo = struct {
closed: bool = false,
fn close(self: *@This()) void {
self.closed = true;
}
};
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var with_io: WithIo = .{};
closeBlocked(io, &with_io);
try testing.expect(with_io.closed);
var without_io: WithoutIo = .{};
closeBlocked(io, &without_io);
try testing.expect(without_io.closed);
}
test "a fake client satisfies the Client interface" { test "a fake client satisfies the Client interface" {
const Fake = struct { const Fake = struct {
calls: usize = 0, calls: usize = 0,
+15 -44
View File
@@ -87,10 +87,7 @@ pub fn applyCreate(
arena: Allocator, arena: Allocator,
item: model.BlocklistSource, item: model.BlocklistSource,
) error{OutOfMemory}!Created { ) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
if (try mutations.checkSource(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } }; if (try mutations.checkSource(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
state.config_lock.lockUncancelable(io); state.config_lock.lockUncancelable(io);
@@ -109,10 +106,7 @@ pub fn applyUpdate(
id: i64, id: i64,
item: model.BlocklistSource, item: model.BlocklistSource,
) error{OutOfMemory}!?Failure { ) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
.database => |value| value,
.fail => |failure| return failure,
};
if (try mutations.checkSource(arena, item)) |problem| return .{ .invalid = problem }; if (try mutations.checkSource(arena, item)) |problem| return .{ .invalid = problem };
state.config_lock.lockUncancelable(io); state.config_lock.lockUncancelable(io);
@@ -124,10 +118,7 @@ pub fn applyUpdate(
} }
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure { pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
.database => |value| value,
.fail => |failure| return failure,
};
state.config_lock.lockUncancelable(io); state.config_lock.lockUncancelable(io);
const written = sources_repo.deleteSource(database, id); const written = sources_repo.deleteSource(database, id);
@@ -189,32 +180,19 @@ pub fn applyRefresh(state: *server.WebState, io: std.Io, out: []manager_mod.Sour
// routes // routes
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const resource = mutations.Resource(.{
_ = io; .Row = sources_repo.SourceRow,
const database = switch (mutations.configDb(state)) { .list = sources_repo.listSourceRows,
.database => |value| value, .get = sources_repo.getSource,
.fail => |failure| return mutations.respondFailure(request, failure, "listing blocklists"), .remove = applyDelete,
}; .label = "a blocklist",
.plural = "blocklists",
.envelope = "blocklists",
});
const rows = sources_repo.listSourceRows(database, request.arena) catch |err| pub const list = resource.list;
return mutations.respondFailure(request, .{ .internal = err }, "listing blocklists"); pub const get = resource.get;
pub const remove = resource.remove;
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 { pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(Body, request) catch |err| const parsed = http_util.parseBody(Body, request) catch |err|
@@ -251,13 +229,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
}, &.{}); }, &.{});
} }
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`. /// `POST /api/blocklists/update`.
pub fn refresh(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { pub fn refresh(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const statuses = try request.arena.alloc(manager_mod.SourceStatus, max_statuses); const statuses = try request.arena.alloc(manager_mod.SourceStatus, max_statuses);
+27 -55
View File
@@ -61,10 +61,7 @@ pub fn applyUpdate(
id: i64, id: i64,
edit: clients_repo.ClientEdit, edit: clients_repo.ClientEdit,
) ?Failure { ) ?Failure {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
.database => |value| value,
.fail => |failure| return failure,
};
state.config_lock.lockUncancelable(io); state.config_lock.lockUncancelable(io);
const written = clients_repo.updateClient(database, id, edit); const written = clients_repo.updateClient(database, id, edit);
@@ -75,10 +72,7 @@ pub fn applyUpdate(
} }
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure { pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
.database => |value| value,
.fail => |failure| return failure,
};
state.config_lock.lockUncancelable(io); state.config_lock.lockUncancelable(io);
const written = clients_repo.deleteClient(database, id); const written = clients_repo.deleteClient(database, id);
@@ -94,10 +88,7 @@ pub fn applyReplacePrefixes(
arena: Allocator, arena: Allocator,
items: []const clients_repo.ClientPrefixInput, items: []const clients_repo.ClientPrefixInput,
) error{OutOfMemory}!?Failure { ) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
.database => |value| value,
.fail => |failure| return failure,
};
// Canonical duplicates are the same UNIQUE collision the database would // Canonical duplicates are the same UNIQUE collision the database would
// report for identical text, so they answer 409 (ruling 9) before the // report for identical text, so they answer 409 (ruling 9) before the
@@ -152,32 +143,33 @@ fn checkPrefixSet(
// routes // routes
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const resource = mutations.Resource(.{
_ = io; .Row = clients_repo.ClientRow,
const database = switch (mutations.configDb(state)) { .list = clients_repo.listClientRows,
.database => |value| value, .get = clients_repo.getClient,
.fail => |failure| return mutations.respondFailure(request, failure, "listing clients"), .remove = applyDelete,
}; .label = "a client",
.plural = "clients",
.envelope = "clients",
});
const rows = clients_repo.listClientRows(database, request.arena) catch |err| pub const list = resource.list;
return mutations.respondFailure(request, .{ .internal = err }, "listing clients"); pub const get = resource.get;
pub const remove = resource.remove;
return http_util.respondJson(request, .ok, .{ .clients = rows.items }, &.{}); /// The prefixes are one list resource with no `/{id}` route: the whole set is
} /// read and replaced (ruling 9), so there is nothing to get or delete by id.
const prefixes_resource = mutations.Resource(.{
.Row = clients_repo.ClientPrefixRow,
.list = clients_repo.listClientPrefixRows,
.get = null,
.remove = null,
.label = "a client prefix",
.plural = "client prefixes",
.envelope = "client_prefixes",
});
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { pub const listPrefixes = prefixes_resource.list;
_ = 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 { pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(ClientBody, request) catch |err| const parsed = http_util.parseBody(ClientBody, request) catch |err|
@@ -199,26 +191,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
return http_util.respondJson(request, .ok, 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 { pub fn putPrefixes(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(PrefixesBody, request) catch |err| const parsed = http_util.parseBody(PrefixesBody, request) catch |err|
return mutations.respondBadBody(request, err); return mutations.respondBadBody(request, err);
+18 -52
View File
@@ -54,10 +54,7 @@ pub fn applyCreate(
arena: Allocator, arena: Allocator,
item: model.Group, item: model.Group,
) error{OutOfMemory}!Created { ) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
if (try mutations.checkGroupName(arena, item.name)) |problem| { if (try mutations.checkGroupName(arena, item.name)) |problem| {
return .{ .fail = .{ .invalid = problem } }; return .{ .fail = .{ .invalid = problem } };
} }
@@ -78,10 +75,7 @@ pub fn applyUpdate(
id: i64, id: i64,
item: model.Group, item: model.Group,
) error{OutOfMemory}!?Failure { ) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
.database => |value| value,
.fail => |failure| return failure,
};
if (try mutations.checkGroupName(arena, item.name)) |problem| { if (try mutations.checkGroupName(arena, item.name)) |problem| {
return .{ .invalid = problem }; return .{ .invalid = problem };
} }
@@ -112,10 +106,7 @@ fn updateLocked(database: *db.Db, arena: Allocator, id: i64, item: model.Group)
} }
pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure { pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
.database => |value| value,
.fail => |failure| return failure,
};
state.config_lock.lockUncancelable(io); state.config_lock.lockUncancelable(io);
const outcome = deleteLocked(database, arena, id); const outcome = deleteLocked(database, arena, id);
@@ -148,10 +139,7 @@ pub fn applySetSources(
id: i64, id: i64,
source_ids: []const i64, source_ids: []const i64,
) ?Failure { ) ?Failure {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
.database => |value| value,
.fail => |failure| return failure,
};
state.config_lock.lockUncancelable(io); state.config_lock.lockUncancelable(io);
const outcome = groups_repo.setGroupSources(database, id, source_ids); const outcome = groups_repo.setGroupSources(database, id, source_ids);
@@ -165,32 +153,19 @@ pub fn applySetSources(
// routes // routes
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const resource = mutations.Resource(.{
_ = io; .Row = groups_repo.GroupRow,
const database = switch (mutations.configDb(state)) { .list = groups_repo.listGroupRows,
.database => |value| value, .get = groups_repo.getGroup,
.fail => |failure| return mutations.respondFailure(request, failure, "listing groups"), .remove = applyDelete,
}; .label = "a group",
.plural = "groups",
.envelope = "groups",
});
const rows = groups_repo.listGroupRows(database, request.arena) catch |err| pub const list = resource.list;
return mutations.respondFailure(request, .{ .internal = err }, "listing groups"); pub const get = resource.get;
pub const remove = resource.remove;
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 { pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(Body, request) catch |err| const parsed = http_util.parseBody(Body, request) catch |err|
@@ -223,20 +198,11 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
}, &.{}); }, &.{});
} }
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. /// `GET /api/groups/{id}/sources` — the assignment the PUT replaces.
pub fn getSources(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { pub fn getSources(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io; _ = io;
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch
.database => |value| value, return mutations.respondFailure(request, mutations.no_config_db, "reading a group");
.fail => |failure| return mutations.respondFailure(request, failure, "reading a group"),
};
const id = request.id.?; const id = request.id.?;
const row = groups_repo.getGroup(database, request.arena, id) catch |err| const row = groups_repo.getGroup(database, request.arena, id) catch |err|
+31 -91
View File
@@ -94,10 +94,7 @@ pub fn applyCreateRecord(
arena: Allocator, arena: Allocator,
item: model.LocalRecord, item: model.LocalRecord,
) error{OutOfMemory}!Created { ) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } }; if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
state.config_lock.lockUncancelable(io); state.config_lock.lockUncancelable(io);
@@ -116,10 +113,7 @@ pub fn applyUpdateRecord(
id: i64, id: i64,
item: model.LocalRecord, item: model.LocalRecord,
) error{OutOfMemory}!?Failure { ) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
.database => |value| value,
.fail => |failure| return failure,
};
if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .invalid = problem }; if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .invalid = problem };
state.config_lock.lockUncancelable(io); state.config_lock.lockUncancelable(io);
@@ -131,10 +125,7 @@ pub fn applyUpdateRecord(
} }
pub fn applyDeleteRecord(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure { pub fn applyDeleteRecord(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
.database => |value| value,
.fail => |failure| return failure,
};
state.config_lock.lockUncancelable(io); state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io); defer state.config_lock.unlock(io);
@@ -154,10 +145,7 @@ pub fn applyCreateZone(
arena: Allocator, arena: Allocator,
item: model.ForwardZone, item: model.ForwardZone,
) error{OutOfMemory}!Created { ) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } }; if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
state.config_lock.lockUncancelable(io); state.config_lock.lockUncancelable(io);
@@ -176,10 +164,7 @@ pub fn applyUpdateZone(
id: i64, id: i64,
item: model.ForwardZone, item: model.ForwardZone,
) error{OutOfMemory}!?Failure { ) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
.database => |value| value,
.fail => |failure| return failure,
};
if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .invalid = problem }; if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .invalid = problem };
state.config_lock.lockUncancelable(io); state.config_lock.lockUncancelable(io);
@@ -191,10 +176,7 @@ pub fn applyUpdateZone(
} }
pub fn applyDeleteZone(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure { pub fn applyDeleteZone(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
.database => |value| value,
.fail => |failure| return failure,
};
state.config_lock.lockUncancelable(io); state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io); defer state.config_lock.unlock(io);
@@ -208,35 +190,20 @@ pub fn applyDeleteZone(state: *server.WebState, io: std.Io, arena: Allocator, id
// local records: routes // local records: routes
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub fn listRecords(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const records_resource = mutations.Resource(.{
_ = io; .Row = local_repo.LocalRecordRow,
const database = switch (mutations.configDb(state)) { .list = local_repo.listLocalRecordRows,
.database => |value| value, .get = local_repo.getLocalRecord,
.fail => |failure| return mutations.respondFailure(request, failure, "listing local records"), .remove = applyDeleteRecord,
}; .label = "a local record",
.plural = "local records",
.envelope = "local_records",
.view = RecordView.from,
});
const rows = local_repo.listLocalRecordRows(database, request.arena) catch |err| pub const listRecords = records_resource.list;
return mutations.respondFailure(request, .{ .internal = err }, "listing local records"); pub const getRecord = records_resource.get;
pub const removeRecord = records_resource.remove;
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 { pub fn createRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(RecordBody, request) catch |err| const parsed = http_util.parseBody(RecordBody, request) catch |err|
@@ -279,43 +246,23 @@ pub fn updateRecord(state: *server.WebState, io: std.Io, request: *Request) Hand
}, &.{}); }, &.{});
} }
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 // forward zones: routes
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub fn listZones(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const zones_resource = mutations.Resource(.{
_ = io; .Row = local_repo.ForwardZoneRow,
const database = switch (mutations.configDb(state)) { .list = local_repo.listForwardZoneRows,
.database => |value| value, .get = local_repo.getForwardZone,
.fail => |failure| return mutations.respondFailure(request, failure, "listing forward zones"), .remove = applyDeleteZone,
}; .label = "a forward zone",
.plural = "forward zones",
.envelope = "forward_zones",
});
const rows = local_repo.listForwardZoneRows(database, request.arena) catch |err| pub const listZones = zones_resource.list;
return mutations.respondFailure(request, .{ .internal = err }, "listing forward zones"); pub const getZone = zones_resource.get;
pub const removeZone = zones_resource.remove;
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 { pub fn createZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(ZoneBody, request) catch |err| const parsed = http_util.parseBody(ZoneBody, request) catch |err|
@@ -348,13 +295,6 @@ pub fn updateZone(state: *server.WebState, io: std.Io, request: *Request) Handle
}, &.{}); }, &.{});
} }
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 // tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+156 -4
View File
@@ -101,16 +101,156 @@ pub fn dbFailure(err: db.Error, conflict: []const u8) Failure {
}; };
} }
/// The config connection, or the 503 a state without one earns. /// The 503 a state with no config connection earns. One constant, because every
pub fn configDb(state: *server.WebState) union(enum) { database: *db.Db, fail: Failure } { /// caller reports the same missing collaborator in the same words.
if (state.config_db) |database| return .{ .database = database }; pub const no_config_db: Failure = .{ .unavailable = "no configuration database" };
return .{ .fail = .{ .unavailable = "no configuration database" } };
/// The config connection, or `error.NoConfigDb` for the caller to turn into
/// `no_config_db` in whatever shape it answers with — a `Failure`, a `Created`,
/// or a written response.
pub fn requireConfigDb(state: *server.WebState) error{NoConfigDb}!*db.Db {
return state.config_db orelse error.NoConfigDb;
} }
pub fn nowSeconds(io: std.Io) i64 { pub fn nowSeconds(io: std.Io) i64 {
return std.Io.Clock.real.now(io).toSeconds(); return std.Io.Clock.real.now(io).toSeconds();
} }
// ---------------------------------------------------------------------------
// the identical half of an id-addressed resource
// ---------------------------------------------------------------------------
/// The `list`, `get` and `remove` handlers every id-addressed resource in this
/// directory writes the same way: take the config connection or answer 503, call
/// one repository function, and turn what comes back into the response. Nothing
/// a resource decides for itself is here — the create and update bodies, the
/// constraint texts, and the four reload flavors stay hand-written beside the
/// descriptor that names these three.
///
/// `desc` is an anonymous struct literal rather than a typed struct because
/// `anytype` is not legal as a struct *field* type and the members are functions
/// of five signatures. Every member is checked below, so a descriptor that is
/// missing one or spells one wrong is a compile error that names it.
///
/// Members:
///
/// - `Row: type` — what the repository returns for one row.
/// - `list: fn (*db.Db, Allocator) db.Error!std.ArrayList(Row)`.
/// - `get: fn (*db.Db, Allocator, i64) db.Error!?Row`, or `null` for a resource
/// with no `/{id}` route.
/// - `remove: fn (*server.WebState, std.Io, i64) ?Failure`, or the same with an
/// `Allocator` before the id for a delete decision that reads rows, or `null`.
/// - `label: []const u8` — "an upstream": what "reading" and "deleting" take as
/// their object in the log context a 500 carries.
/// - `plural: []const u8` — "upstreams": what "listing" takes as its object.
/// - `envelope: []const u8` — the JSON key the list arrives under.
/// - `view: fn (Row) View` — optional. A resource whose wire shape is not its
/// row spells the difference here; without it the row is serialised as it is.
pub fn Resource(comptime desc: anytype) type {
const Desc = @TypeOf(desc);
for ([_][]const u8{ "Row", "list", "get", "remove", "label", "plural", "envelope" }) |name| {
if (!@hasField(Desc, name)) {
@compileError("resource descriptor has no `" ++ name ++ "`");
}
}
if (@TypeOf(desc.Row) != type) @compileError("resource descriptor `Row` must be a type");
const Row = desc.Row;
expectType("list", @TypeOf(desc.list), fn (*db.Db, Allocator) db.Error!std.ArrayList(Row));
if (!isNull(@TypeOf(desc.get))) {
expectType("get", @TypeOf(desc.get), fn (*db.Db, Allocator, i64) db.Error!?Row);
}
if (!isNull(@TypeOf(desc.remove))) {
const Remove = @TypeOf(desc.remove);
if (removeTakesArena(Remove)) {
expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) ?Failure);
} else {
expectType("remove", Remove, fn (*server.WebState, std.Io, i64) ?Failure);
}
}
_ = @as([]const u8, desc.label);
_ = @as([]const u8, desc.plural);
_ = @as([]const u8, desc.envelope);
const has_view = @hasField(Desc, "view");
if (has_view) {
const info = @typeInfo(@TypeOf(desc.view)).@"fn";
if (info.params.len != 1 or info.params[0].type.? != Row) {
@compileError("resource descriptor `view` must take one " ++ @typeName(Row));
}
}
const View = if (has_view) @typeInfo(@TypeOf(desc.view)).@"fn".return_type.? else Row;
const names: [1][:0]const u8 = .{desc.envelope};
const types: [1]type = .{[]const View};
const attrs: [1]std.builtin.Type.StructField.Attributes = .{.{}};
const Envelope = @Struct(.auto, null, &names, &types, &attrs);
const list_what = "listing " ++ desc.plural;
const get_what = "reading " ++ desc.label;
const remove_what = "deleting " ++ desc.label;
return struct {
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = requireConfigDb(state) catch
return respondFailure(request, no_config_db, list_what);
const rows = desc.list(database, request.arena) catch |err|
return respondFailure(request, .{ .internal = err }, list_what);
const items: []const View = if (has_view) views: {
const views = try request.arena.alloc(View, rows.items.len);
for (views, rows.items) |*view, row| view.* = desc.view(row);
break :views views;
} else rows.items;
var payload: Envelope = undefined;
@field(payload, desc.envelope) = items;
return http_util.respondJson(request, .ok, payload, &.{});
}
pub const get = if (isNull(@TypeOf(desc.get))) {} else getRow;
pub const remove = if (isNull(@TypeOf(desc.remove))) {} else removeRow;
fn getRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = requireConfigDb(state) catch
return respondFailure(request, no_config_db, get_what);
const row = desc.get(database, request.arena, request.id.?) catch |err|
return respondFailure(request, .{ .internal = err }, get_what);
const found = row orelse return respondFailure(request, .not_found, "");
const body: View = if (has_view) desc.view(found) else found;
return http_util.respondJson(request, .ok, body, &.{});
}
fn removeRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const failure = if (comptime removeTakesArena(@TypeOf(desc.remove)))
desc.remove(state, io, request.arena, request.id.?)
else
desc.remove(state, io, request.id.?);
if (failure) |value| return respondFailure(request, value, remove_what);
return http_util.respondEmpty(request, .no_content);
}
};
}
fn isNull(comptime T: type) bool {
return T == @TypeOf(null);
}
fn removeTakesArena(comptime T: type) bool {
return @typeInfo(T) == .@"fn" and @typeInfo(T).@"fn".params.len == 4;
}
fn expectType(comptime name: []const u8, comptime Actual: type, comptime Expected: type) void {
if (Actual != Expected) @compileError("resource descriptor `" ++ name ++ "` must be " ++
@typeName(Expected) ++ ", found " ++ @typeName(Actual));
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// applying a change to the running server (ruling 12) // applying a change to the running server (ruling 12)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -439,6 +579,18 @@ test "the schema the bench opens already holds the default group" {
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT id FROM groups WHERE name = 'default'")); try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT id FROM groups WHERE name = 'default'"));
} }
test "a state with no configuration database is a 503, not a crash" {
var bench: Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try testing.expectEqual(&bench.database, try requireConfigDb(&bench.state));
var bare: server.WebState = .{ .gpa = testing.allocator };
try testing.expectError(error.NoConfigDb, requireConfigDb(&bare));
try testing.expectEqualStrings("no configuration database", no_config_db.unavailable);
}
test "a database error maps to the status its cause deserves" { test "a database error maps to the status its cause deserves" {
try testing.expectEqual(Failure.not_found, dbFailure(error.NotFound, "x")); try testing.expectEqual(Failure.not_found, dbFailure(error.NotFound, "x"));
try testing.expectEqualStrings("taken", dbFailure(error.Constraint, "taken").conflict); try testing.expectEqualStrings("taken", dbFailure(error.Constraint, "taken").conflict);
+16 -47
View File
@@ -60,10 +60,7 @@ pub fn applyCreate(
arena: Allocator, arena: Allocator,
item: rules_repo.RuleInput, item: rules_repo.RuleInput,
) error{OutOfMemory}!Created { ) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| { if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| {
return .{ .fail = .{ .invalid = problem } }; return .{ .fail = .{ .invalid = problem } };
} }
@@ -84,10 +81,7 @@ pub fn applyUpdate(
id: i64, id: i64,
item: rules_repo.RuleInput, item: rules_repo.RuleInput,
) error{OutOfMemory}!?Failure { ) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
.database => |value| value,
.fail => |failure| return failure,
};
if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| { if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| {
return .{ .invalid = problem }; return .{ .invalid = problem };
} }
@@ -101,10 +95,7 @@ pub fn applyUpdate(
} }
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure { pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
.database => |value| value,
.fail => |failure| return failure,
};
state.config_lock.lockUncancelable(io); state.config_lock.lockUncancelable(io);
const written = rules_repo.deleteRule(database, id); const written = rules_repo.deleteRule(database, id);
@@ -142,35 +133,20 @@ const RuleView = struct {
} }
}; };
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const resource = mutations.Resource(.{
_ = io; .Row = rules_repo.RuleRow,
const database = switch (mutations.configDb(state)) { .list = rules_repo.listRuleRows,
.database => |value| value, .get = rules_repo.getRule,
.fail => |failure| return mutations.respondFailure(request, failure, "listing rules"), .remove = applyDelete,
}; .label = "a rule",
.plural = "rules",
.envelope = "rules",
.view = RuleView.from,
});
const rows = rules_repo.listRuleRows(database, request.arena) catch |err| pub const list = resource.list;
return mutations.respondFailure(request, .{ .internal = err }, "listing rules"); pub const get = resource.get;
pub const remove = resource.remove;
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 { pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(Body, request) catch |err| const parsed = http_util.parseBody(Body, request) catch |err|
@@ -213,13 +189,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
}, &.{}); }, &.{});
} }
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 // tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+3 -8
View File
@@ -281,10 +281,7 @@ pub fn applyPut(
arena: Allocator, arena: Allocator,
patch: Patch, patch: Patch,
) error{OutOfMemory}!union(enum) { config: model.Config, fail: Failure } { ) error{OutOfMemory}!union(enum) { config: model.Config, fail: Failure } {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
// Ruling 18 of milestone 16: argon2id at m=19 MiB is the longest thing this // Ruling 18 of milestone 16: argon2id at m=19 MiB is the longest thing this
// handler does, and its input is the parsed patch alone — nothing under the // handler does, and its input is the parsed patch alone — nothing under the
@@ -449,10 +446,8 @@ pub const hash_stall_control = if (builtin.is_test) struct {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch
.database => |value| value, return mutations.respondFailure(request, mutations.no_config_db, "reading the settings");
.fail => |failure| return mutations.respondFailure(request, failure, "reading the settings"),
};
// Under the same lock the mutation handlers hold: a PUT rewrites every // Under the same lock the mutation handlers hold: a PUT rewrites every
// settings row in one transaction on this shared connection, and SQLite's // settings row in one transaction on this shared connection, and SQLite's
+15 -44
View File
@@ -44,10 +44,7 @@ pub fn applyCreate(
arena: Allocator, arena: Allocator,
item: model.UpstreamServer, item: model.UpstreamServer,
) error{OutOfMemory}!Created { ) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
if (try mutations.checkUpstream(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } }; if (try mutations.checkUpstream(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
state.config_lock.lockUncancelable(io); state.config_lock.lockUncancelable(io);
@@ -65,10 +62,7 @@ pub fn applyUpdate(
id: i64, id: i64,
item: model.UpstreamServer, item: model.UpstreamServer,
) error{OutOfMemory}!?Failure { ) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
.database => |value| value,
.fail => |failure| return failure,
};
if (try mutations.checkUpstream(arena, item)) |problem| return .{ .invalid = problem }; if (try mutations.checkUpstream(arena, item)) |problem| return .{ .invalid = problem };
state.config_lock.lockUncancelable(io); state.config_lock.lockUncancelable(io);
@@ -96,10 +90,7 @@ pub fn applyUpdate(
/// answers nothing, and `validate.validate` refuses that configuration at /// answers nothing, and `validate.validate` refuses that configuration at
/// startup — so allowing it here would only produce a box that will not boot. /// 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 { pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
.database => |value| value,
.fail => |failure| return failure,
};
state.config_lock.lockUncancelable(io); state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io); defer state.config_lock.unlock(io);
@@ -142,32 +133,19 @@ fn countEnabledExcept(
// routes // routes
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const resource = mutations.Resource(.{
_ = io; .Row = upstreams_repo.UpstreamRow,
const database = switch (mutations.configDb(state)) { .list = upstreams_repo.listUpstreamRows,
.database => |value| value, .get = upstreams_repo.getUpstream,
.fail => |failure| return mutations.respondFailure(request, failure, "listing upstreams"), .remove = applyDelete,
}; .label = "an upstream",
.plural = "upstreams",
.envelope = "upstreams",
});
const rows = upstreams_repo.listUpstreamRows(database, request.arena) catch |err| pub const list = resource.list;
return mutations.respondFailure(request, .{ .internal = err }, "listing upstreams"); pub const get = resource.get;
pub const remove = resource.remove;
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 { pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(Body, request) catch |err| const parsed = http_util.parseBody(Body, request) catch |err|
@@ -206,13 +184,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
}, &.{}); }, &.{});
} }
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 { fn toModel(body: Body) model.UpstreamServer {
return .{ return .{
.url = body.url, .url = body.url,
+8 -8
View File
@@ -776,7 +776,7 @@ test "the plain-DNS listener families carry every counter of both listeners" {
.send_errors = 5, .send_errors = 5,
}, },
.tcp_listener = .{ .tcp_listener = .{
.accepted = 12, .connections = 12,
.rejected_at_capacity = 6, .rejected_at_capacity = 6,
.rejected_at_shutdown = 7, .rejected_at_shutdown = 7,
.accept_errors = 8, .accept_errors = 8,
@@ -793,7 +793,7 @@ test "the plain-DNS listener families carry every counter of both listeners" {
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_receive_errors_total 4\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_receive_errors_total 4\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_send_errors_total 5\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_send_errors_total 5\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_accepted_total 12\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_connections_total 12\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_capacity_total 6\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_capacity_total 6\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_shutdown_total 7\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_shutdown_total 7\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_accept_errors_total 8\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_accept_errors_total 8\n"));
@@ -822,13 +822,13 @@ test "one family covers all four listeners, summed" {
udp4.stats.dropped_no_slot.store(2, .monotonic); udp4.stats.dropped_no_slot.store(2, .monotonic);
var tcp6: tcp_server.TcpServer = undefined; var tcp6: tcp_server.TcpServer = undefined;
tcp6.stats = .{}; tcp6.core.stats = .{};
tcp6.stats.accepted.store(4, .monotonic); tcp6.core.stats.connections.store(4, .monotonic);
var tcp4: tcp_server.TcpServer = undefined; var tcp4: tcp_server.TcpServer = undefined;
tcp4.stats = .{}; tcp4.core.stats = .{};
tcp4.stats.accepted.store(5, .monotonic); tcp4.core.stats.connections.store(5, .monotonic);
tcp4.stats.idle_timeouts.store(3, .monotonic); tcp4.core.stats.idle_timeouts.store(3, .monotonic);
const udp = sumListeners(udp_server.Snapshot, udp_server.UdpServer, &.{ &udp6, &udp4 }).?; const udp = sumListeners(udp_server.Snapshot, udp_server.UdpServer, &.{ &udp6, &udp4 }).?;
try testing.expectEqual(@as(u64, 17), udp.received); try testing.expectEqual(@as(u64, 17), udp.received);
@@ -836,7 +836,7 @@ test "one family covers all four listeners, summed" {
try testing.expectEqual(@as(u64, 0), udp.send_errors); try testing.expectEqual(@as(u64, 0), udp.send_errors);
const tcp = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, &.{ &tcp6, &tcp4 }).?; const tcp = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, &.{ &tcp6, &tcp4 }).?;
try testing.expectEqual(@as(u64, 9), tcp.accepted); try testing.expectEqual(@as(u64, 9), tcp.connections);
try testing.expectEqual(@as(u64, 3), tcp.idle_timeouts); try testing.expectEqual(@as(u64, 3), tcp.idle_timeouts);
// No listener at all is a missing family, not a family of zeros. // No listener at all is a missing family, not a family of zeros.
+68 -283
View File
@@ -1,20 +1,11 @@
//! The admin HTTP listener. //! The admin HTTP listener.
//! //!
//! One `std.http.Server` per connection over our own accept loop: a listener //! One `std.http.Server` per connection over the shared `listener.Core` accept
//! task in the app's group, an inner `Io.Group` of connection tasks, and a //! loop (milestone-18 ruling 1): a listener task in the app's group, an inner
//! keep-alive loop per connection that ends on `error.HttpConnectionClosing`. //! `Io.Group` of connection tasks, and a keep-alive loop per connection that
//! The shape is lib/std/Build/WebServer.zig:152-185; the shutdown split is //! ends on `error.HttpConnectionClosing`. The shape is
//! tcp_server.zig's, for the same reason. //! lib/std/Build/WebServer.zig:152-185; the slot pool and the shutdown split
//! //! come from the core, which documents both.
//! 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 //! 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 //! a request needs, so serving allocates only what a handler asks the
@@ -41,6 +32,7 @@ const dns_handler = @import("../server/handler.zig");
const doh_server = @import("../server/doh_server.zig"); const doh_server = @import("../server/doh_server.zig");
const dot_server = @import("../server/dot_server.zig"); const dot_server = @import("../server/dot_server.zig");
const http_util = @import("http_util.zig"); const http_util = @import("http_util.zig");
const listener_core = @import("../server/listener.zig");
const local_tables_mod = @import("../server/local_tables.zig"); const local_tables_mod = @import("../server/local_tables.zig");
const logger_mod = @import("../storage/logger.zig"); const logger_mod = @import("../storage/logger.zig");
const manager_mod = @import("../filter/manager.zig"); const manager_mod = @import("../filter/manager.zig");
@@ -69,10 +61,6 @@ pub const default_max_connections: u16 = 64;
/// connections cost 4 MiB rather than 64. /// connections cost 4 MiB rather than 64.
const arena_retain_bytes = 64 * 1024; 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_body = "{\"error\":\"too many connections\"}";
const over_capacity_response = std.fmt.comptimePrint( const over_capacity_response = std.fmt.comptimePrint(
"HTTP/1.1 503 Service Unavailable\r\n" ++ "HTTP/1.1 503 Service Unavailable\r\n" ++
@@ -232,12 +220,10 @@ pub fn neverLimit(state: *WebState, io: std.Io, request: *const http_util.Reques
return .ok; return .ok;
} }
/// What the admin listener counts on top of `listener.CoreStats`. Nothing
/// exports these: there is no `nxdns_web_*` family, they exist for the
/// integration tests and for a future one.
pub const Stats = struct { 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), requests: std.atomic.Value(u64) = .init(0),
}; };
@@ -245,41 +231,15 @@ pub const Options = struct {
max_connections: u16 = default_max_connections, 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 { pub const Server = struct {
core: listener_core.Core(Config),
state: *WebState, 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, 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 /// 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). /// `request.head` dies on the first body read (http/Server.zig:594). The
pub const Conn = struct { /// receive and send buffers belong to the core.
recv_buf: [recv_buffer_len]u8, pub const Payload = struct {
send_buf: [send_buffer_len]u8,
target_buf: [http_util.max_target_len]u8, target_buf: [http_util.max_target_len]u8,
cookie_buf: [http_util.max_cookie_len]u8, cookie_buf: [http_util.max_cookie_len]u8,
accept_encoding_buf: [http_util.max_header_value_len]u8, accept_encoding_buf: [http_util.max_header_value_len]u8,
@@ -288,13 +248,31 @@ pub const Server = struct {
/// Per-request working memory, reset between requests on the same /// Per-request working memory, reset between requests on the same
/// connection so a keep-alive client cannot grow it without bound. /// connection so a keep-alive client cannot grow it without bound.
arena: std.heap.ArenaAllocator, arena: std.heap.ArenaAllocator,
stream: net.Stream,
peer: net.IpAddress, fn init(payload: *Payload, gpa: Allocator) void {
/// Guarded by `Server.mutex`. payload.arena = .init(gpa);
conn_state: ConnState, }
fn deinit(payload: *Payload) void {
payload.arena.deinit();
}
}; };
pub const ListenError = net.IpAddress.ListenError || error{OutOfMemory}; const Config = struct {
pub const Owner = Server;
pub const ConnPayload = Payload;
pub const serveConn = serveOne;
pub const read_buffer_len = recv_buffer_len;
pub const write_buffer_len = send_buffer_len;
pub const log = std.log.scoped(.web_server);
pub const name = "web";
pub const refuse = refuseOverCapacity;
pub const initPayload = Payload.init;
pub const deinitPayload = Payload.deinit;
};
pub const Conn = listener_core.Core(Config).Conn;
pub const ListenError = listener_core.Core(Config).ListenError;
pub fn listen( pub fn listen(
gpa: Allocator, gpa: Allocator,
@@ -303,128 +281,35 @@ pub const Server = struct {
state: *WebState, state: *WebState,
options: Options, options: Options,
) ListenError!Server { ) 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 .{ return .{
.core = try listener_core.Core(Config).listen(gpa, io, listen_address, options.max_connections),
.state = state, .state = state,
.listener = listener,
.conns = conns,
.mutex = .init,
.shutdown_begun = false,
.stats = .{}, .stats = .{},
.run_state = .init(.idle),
.stopped = .unset,
}; };
} }
/// The kernel-assigned address. A port of 0 in `listen` resolves here. /// The kernel-assigned address. A port of 0 in `listen` resolves here.
pub fn boundAddress(self: *const Server) net.IpAddress { pub fn boundAddress(self: *const Server) net.IpAddress {
return self.listener.socket.address; return self.core.boundAddress();
} }
/// Accept loop. Returns when the task is canceled or `deinit` stops it. /// Accept loop. Returns when the task is canceled or `deinit` stops it.
pub fn serve(self: *Server, io: std.Io) void { pub fn serve(self: *Server, io: std.Io) void {
if (self.run_state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return; self.core.serve(io);
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 { pub fn deinit(self: *Server, io: std.Io) void {
const was_serving = self.run_state.swap(.closing, .acq_rel) == .serving; // Ruling 11 of milestone 16, before the core shuts the connections
// down: a live-query task parked in `Hub.wait` is waiting on an event,
// Shutting the listening socket down is the documented way to unblock a // not on its socket, so shutting the connection down does not reach it.
// pending `accept`: it fails with `error.SocketNotListening`. // Without this the drain waits out one heartbeat interval per idle
const listener: net.Stream = .{ .socket = self.listener.socket }; // stream.
listener.shutdown(io, .both) catch |err| {
log.debug("web listener shutdown failed: {t}", .{err});
};
// Ruling 11 of milestone 16, before `beginShutdown`: a live-query task
// parked in `Hub.wait` is waiting on an event, not on its socket, so
// shutting the connection down does not reach it. Without this the
// drain below waits out one heartbeat interval per idle stream.
if (self.state.hub) |hub| hub.close(io); if (self.state.hub) |hub| hub.close(io);
self.beginShutdown(io); self.core.deinit(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; 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. /// Ruling 7: over capacity the client is told so, never silently dropped.
/// ///
/// The response is written from the accept loop, because refusing must not /// The response is written from the accept loop, because refusing must not
@@ -437,7 +322,7 @@ pub const Server = struct {
/// would mean a blocking read on the accept loop with no bound but the /// 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 /// client's goodwill, which is a worse failure than a lost error page on a
/// server that is already at capacity. /// server that is already at capacity.
fn refuse(io: std.Io, stream: net.Stream) void { fn refuseOverCapacity(io: std.Io, stream: net.Stream) void {
var buf: [over_capacity_response.len]u8 = undefined; var buf: [over_capacity_response.len]u8 = undefined;
var writer = stream.writer(io, &buf); var writer = stream.writer(io, &buf);
writer.interface.writeAll(over_capacity_response) catch {}; writer.interface.writeAll(over_capacity_response) catch {};
@@ -445,12 +330,12 @@ pub const Server = struct {
stream.close(io); stream.close(io);
} }
fn serveConn(self: *Server, io: std.Io, index: usize) void { /// One connection's keep-alive loop. The core closes the slot when this
defer self.finish(io, index); /// returns.
fn serveOne(self: *Server, io: std.Io, index: usize) void {
const conn = &self.conns[index]; const conn = &self.core.conns[index];
var reader = conn.stream.reader(io, &conn.recv_buf); var reader = conn.stream.reader(io, &conn.read_buf);
var writer = conn.stream.writer(io, &conn.send_buf); var writer = conn.stream.writer(io, &conn.write_buf);
var connection: http.Server = .init(&reader.interface, &writer.interface); var connection: http.Server = .init(&reader.interface, &writer.interface);
while (connection.reader.state == .ready) { while (connection.reader.state == .ready) {
@@ -461,11 +346,11 @@ pub const Server = struct {
// worth a counter. // worth a counter.
error.ReadFailed => return, error.ReadFailed => return,
error.HttpHeadersOversize => { error.HttpHeadersOversize => {
bump(&self.stats.connection_errors); listener_core.bump(&self.core.stats.connection_errors);
return; return;
}, },
error.HttpRequestTruncated, error.HttpHeadersInvalid => { error.HttpRequestTruncated, error.HttpHeadersInvalid => {
bump(&self.stats.connection_errors); listener_core.bump(&self.core.stats.connection_errors);
return; return;
}, },
}; };
@@ -484,17 +369,17 @@ pub const Server = struct {
request.head.content_length = 0; request.head.content_length = 0;
} }
bump(&self.stats.requests); listener_core.bump(&self.stats.requests);
// Retained with a limit, not wholesale: a single 1 MiB body would // Retained with a limit, not wholesale: a single 1 MiB body would
// otherwise keep a megabyte per slot alive for as long as the // otherwise keep a megabyte per slot alive for as long as the
// browser holds the connection. // browser holds the connection.
_ = conn.arena.reset(.{ .retain_with_limit = arena_retain_bytes }); _ = conn.payload.arena.reset(.{ .retain_with_limit = arena_retain_bytes });
self.handleRequest(io, conn, &request) catch |err| switch (err) { self.handleRequest(io, conn, &request) catch |err| switch (err) {
// Ruling 28: the peer went away mid-response. Normal. // Ruling 28: the peer went away mid-response. Normal.
error.WriteFailed => return, error.WriteFailed => return,
error.HttpExpectationFailed, error.OutOfMemory => { error.HttpExpectationFailed, error.OutOfMemory => {
bump(&self.stats.connection_errors); listener_core.bump(&self.core.stats.connection_errors);
return; return;
}, },
}; };
@@ -509,26 +394,26 @@ pub const Server = struct {
conn: *Conn, conn: *Conn,
request: *http.Server.Request, request: *http.Server.Request,
) http_util.HandlerError!void { ) http_util.HandlerError!void {
const arena = conn.arena.allocator(); const arena = conn.payload.arena.allocator();
const target = request.head.target; const target = request.head.target;
if (target.len > conn.target_buf.len) { if (target.len > conn.payload.target_buf.len) {
var view = bareRequest(request, conn, arena); var view = bareRequest(request, conn, arena);
return http_util.respondError(&view, .uri_too_long, "target too long"); return http_util.respondError(&view, .uri_too_long, "target too long");
} }
@memcpy(conn.target_buf[0..target.len], target); @memcpy(conn.payload.target_buf[0..target.len], target);
const copied = conn.target_buf[0..target.len]; const copied = conn.payload.target_buf[0..target.len];
const split = std.mem.findScalar(u8, copied, '?') orelse copied.len; const split = std.mem.findScalar(u8, copied, '?') orelse copied.len;
const raw_path = copied[0..split]; const raw_path = copied[0..split];
const query = if (split == copied.len) copied[split..] else copied[split + 1 ..]; const query = if (split == copied.len) copied[split..] else copied[split + 1 ..];
const cookie = copyCookie(request, &conn.cookie_buf); const cookie = copyCookie(request, &conn.payload.cookie_buf);
const accept_encoding = copyHeader(request, "accept-encoding", &conn.accept_encoding_buf); const accept_encoding = copyHeader(request, "accept-encoding", &conn.payload.accept_encoding_buf);
const if_none_match = copyHeader(request, "if-none-match", &conn.if_none_match_buf); const if_none_match = copyHeader(request, "if-none-match", &conn.payload.if_none_match_buf);
const peer = address.NetAddress.fromIp(conn.peer); const peer = address.NetAddress.fromIp(conn.peer);
const forwarded_for = copyHeaderSuffix(request, "x-forwarded-for", &conn.xff_buf); const forwarded_for = copyHeaderSuffix(request, "x-forwarded-for", &conn.payload.xff_buf);
const client_addr = switch (clientAddr(self.state.web.trusted_proxies, peer, forwarded_for)) { const client_addr = switch (clientAddr(self.state.web.trusted_proxies, peer, forwarded_for)) {
.addr => |addr| addr, .addr => |addr| addr,
.bad_forwarded_for => { .bad_forwarded_for => {
@@ -585,58 +470,6 @@ pub const Server = struct {
.arena = arena, .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 /// Copies one header value into `buf`. A value too long for its budget reads as
@@ -758,19 +591,6 @@ fn sessionPairOnly(value: []const u8, buf: []u8) []const u8 {
return buf[0..len]; return buf[0..len];
} }
/// 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. /// The composition root's entry point: bind, serve, release.
/// ///
/// A bind failure is warned and swallowed. The admin UI failing to come up must /// A bind failure is warned and swallowed. The admin UI failing to come up must
@@ -786,7 +606,7 @@ pub fn serve(state: *WebState, io: std.Io) void {
log.warn("web interface cannot listen on {s}:{d}: {t}", .{ state.web.bind, state.web.port, err }); log.warn("web interface cannot listen on {s}:{d}: {t}", .{ state.web.bind, state.web.port, err });
return; return;
}; };
defer server.deinit(state.gpa, io); defer server.deinit(io);
log.info("web interface listening on {f}", .{server.boundAddress()}); log.info("web interface listening on {f}", .{server.boundAddress()});
server.serve(io); server.serve(io);
@@ -794,41 +614,6 @@ pub fn serve(state: *WebState, io: std.Io) void {
const testing = std.testing; 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" { test "the over-capacity response is a well formed 503" {
try testing.expect(std.mem.startsWith(u8, over_capacity_response, "HTTP/1.1 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").?; const split = std.mem.findPosLinear(u8, over_capacity_response, 0, "\r\n\r\n").?;
+30 -17
View File
@@ -238,6 +238,19 @@ 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; return std.fmt.bufPrint(buf, "GET {s} HTTP/1.1\r\nhost: t\r\n\r\n", .{path}) catch unreachable;
} }
/// The listener's counters, read once after `f` finished and before the
/// listener is torn down. Plain values, because they are a report of a run that
/// is over: the shared core's counters and the web listener's own one land in
/// the same struct here.
const Counters = struct {
connections: u64,
rejected_at_capacity: u64,
rejected_at_shutdown: u64,
accept_errors: u64,
connection_errors: u64,
requests: u64,
};
/// Starts a listener on 127.0.0.1:0 with `state` and runs `f` against it under /// 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. /// the budget, then shuts the listener down through the drain path.
fn withServer( fn withServer(
@@ -247,7 +260,7 @@ fn withServer(
max_connections: u16, max_connections: u16,
comptime f: anytype, comptime f: anytype,
extra: anytype, extra: anytype,
) !server.Stats { ) !Counters {
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0); 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 }); var web = try server.Server.listen(gpa, io, listen_address, state, .{ .max_connections = max_connections });
const address = web.boundAddress(); const address = web.boundAddress();
@@ -257,16 +270,16 @@ fn withServer(
const result = bounded(io, f, .{ io, address } ++ extra); const result = bounded(io, f, .{ io, address } ++ extra);
const stats: server.Stats = .{ const stats: Counters = .{
.accepted = .init(web.stats.accepted.load(.monotonic)), .connections = web.core.stats.connections.load(.monotonic),
.rejected_at_capacity = .init(web.stats.rejected_at_capacity.load(.monotonic)), .rejected_at_capacity = web.core.stats.rejected_at_capacity.load(.monotonic),
.rejected_at_shutdown = .init(web.stats.rejected_at_shutdown.load(.monotonic)), .rejected_at_shutdown = web.core.stats.rejected_at_shutdown.load(.monotonic),
.accept_errors = .init(web.stats.accept_errors.load(.monotonic)), .accept_errors = web.core.stats.accept_errors.load(.monotonic),
.connection_errors = .init(web.stats.connection_errors.load(.monotonic)), .connection_errors = web.core.stats.connection_errors.load(.monotonic),
.requests = .init(web.stats.requests.load(.monotonic)), .requests = web.stats.requests.load(.monotonic),
}; };
web.deinit(gpa, io); web.deinit(io);
group.await(io) catch |err| switch (err) { group.await(io) catch |err| switch (err) {
error.Canceled => unreachable, error.Canceled => unreachable,
}; };
@@ -302,9 +315,9 @@ test "one connection carries two requests" {
const stats = try withServer(gpa, io, &state, 4, twoRequestsOnOneConnection, .{}); const stats = try withServer(gpa, io, &state, 4, twoRequestsOnOneConnection, .{});
// One accept for two requests is the whole point of keep-alive. // 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, 1), stats.connections);
try testing.expectEqual(@as(u64, 2), stats.requests.load(.monotonic)); try testing.expectEqual(@as(u64, 2), stats.requests);
try testing.expectEqual(@as(u64, 0), stats.connection_errors.load(.monotonic)); try testing.expectEqual(@as(u64, 0), stats.connection_errors);
} }
fn routingMatrix(io: std.Io, address: net.IpAddress) anyerror!void { fn routingMatrix(io: std.Io, address: net.IpAddress) anyerror!void {
@@ -352,7 +365,7 @@ test "routing answers 404, 405 with allow, and rejects malformed targets" {
var state = testState(gpa); var state = testState(gpa);
const stats = try withServer(gpa, io, &state, 4, routingMatrix, .{}); const stats = try withServer(gpa, io, &state, 4, routingMatrix, .{});
try testing.expectEqual(@as(u64, 5), stats.requests.load(.monotonic)); try testing.expectEqual(@as(u64, 5), stats.requests);
} }
fn postBody(io: std.Io, address: net.IpAddress, length: usize, expected_status: u16) anyerror!void { fn postBody(io: std.Io, address: net.IpAddress, length: usize, expected_status: u16) anyerror!void {
@@ -448,7 +461,7 @@ test "a POST with no content-length and no transfer-encoding is an empty body, n
var state = testState(gpa); var state = testState(gpa);
const stats = try withServer(gpa, io, &state, 4, postWithoutLength, .{}); const stats = try withServer(gpa, io, &state, 4, postWithoutLength, .{});
try testing.expectEqual(@as(u64, 0), stats.connection_errors.load(.monotonic)); try testing.expectEqual(@as(u64, 0), stats.connection_errors);
} }
fn bodyThenTarget(io: std.Io, address: net.IpAddress) anyerror!void { fn bodyThenTarget(io: std.Io, address: net.IpAddress) anyerror!void {
@@ -509,8 +522,8 @@ test "a connection over the cap is told 503, not silently dropped" {
var state = testState(gpa); var state = testState(gpa);
const stats = try withServer(gpa, io, &state, 1, refusedOverCapacity, .{}); 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.connections);
try testing.expectEqual(@as(u64, 1), stats.rejected_at_capacity.load(.monotonic)); try testing.expectEqual(@as(u64, 1), stats.rejected_at_capacity);
} }
fn deniedAndLimited(io: std.Io, address: net.IpAddress) anyerror!void { fn deniedAndLimited(io: std.Io, address: net.IpAddress) anyerror!void {
@@ -642,7 +655,7 @@ test "cancellation returns promptly with an idle keep-alive connection open" {
const elapsed = start.durationTo(std.Io.Clock.awake.now(io)); const elapsed = start.durationTo(std.Io.Clock.awake.now(io));
client.cancel(io); client.cancel(io);
web.deinit(gpa, io); web.deinit(io);
try testing.expect(elapsed.toMilliseconds() < settle.raw.toMilliseconds()); try testing.expect(elapsed.toMilliseconds() < settle.raw.toMilliseconds());
} }
+1 -1
View File
@@ -402,7 +402,7 @@ const Env = struct {
const gpa = self.gpa; const gpa = self.gpa;
const ioh = self.threaded.io(); const ioh = self.threaded.io();
self.web.deinit(gpa, ioh); self.web.deinit(ioh);
self.group.await(ioh) catch |err| switch (err) { self.group.await(ioh) catch |err| switch (err) {
error.Canceled => unreachable, error.Canceled => unreachable,
}; };
+5 -42
View File
@@ -29,7 +29,10 @@
const std = @import("std"); const std = @import("std");
const parsers = @import("parsers"); const parsers = @import("parsers");
const smith_encode = @import("smith_encode.zig");
const sliceInput = smith_encode.sliceInput;
const pairInput = smith_encode.pairInput;
const wildcard = parsers.wildcard; const wildcard = parsers.wildcard;
const Smith = std.testing.Smith; const Smith = std.testing.Smith;
@@ -132,9 +135,8 @@ fn labelCount(text: []const u8) usize {
// corpus // corpus
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// //
// `Smith` does not consume a corpus entry as raw parser input. It reads a byte // `Smith` does not consume a corpus entry as raw parser input, so every entry
// stream in which a slice is a little-endian `u32` length followed by that many // below goes through the `smith_encode.zig` encoders. The five targets share one
// bytes, so every entry below is length-prefixed. The five targets share one
// corpus: each starts with a slice, and the wildcard target reads a second one // corpus: each starts with a slice, and the wildcard target reads a second one
// that falls back to empty when an entry carries only the first. // that falls back to empty when an entry carries only the first.
@@ -156,31 +158,6 @@ const long_line = "a" ** 5000 ++ ".example.com";
const element_hiding = "example.com##.ad-banner"; const element_hiding = "example.com##.ad-banner";
const scheme_anchor = "|https://ads.example.com/track"; const scheme_anchor = "|https://ads.example.com/track";
/// Encodes `bytes` as a single `Smith.slice` value.
fn sliceInput(comptime bytes: []const u8) *const [4 + bytes.len]u8 {
return &struct {
const value: [4 + bytes.len]u8 = blk: {
var buf: [4 + bytes.len]u8 = undefined;
std.mem.writeInt(u32, buf[0..4], @intCast(bytes.len), .little);
buf[4..].* = bytes[0..bytes.len].*;
break :blk buf;
};
}.value;
}
/// Encodes two `Smith.slice` values back to back, which is what the wildcard
/// target reads.
fn pairInput(comptime a: []const u8, comptime b: []const u8) *const [8 + a.len + b.len]u8 {
return &struct {
const value: [8 + a.len + b.len]u8 = blk: {
var buf: [8 + a.len + b.len]u8 = undefined;
buf[0 .. 4 + a.len].* = sliceInput(a).*;
buf[4 + a.len ..].* = sliceInput(b).*;
break :blk buf;
};
}.value;
}
const corpus = [_][]const u8{ const corpus = [_][]const u8{
sliceInput(hosts_line), sliceInput(hosts_line),
sliceInput(abp_line), sliceInput(abp_line),
@@ -197,17 +174,3 @@ const corpus = [_][]const u8{
pairInput("*.example.com", "example.com.evil.net"), pairInput("*.example.com", "example.com.evil.net"),
pairInput("ad*.example.com", "ads.example.com"), pairInput("ad*.example.com", "ads.example.com"),
}; };
test "a corpus entry carries its own length" {
const encoded = sliceInput(abp_line);
try std.testing.expectEqual(@as(u32, abp_line.len), std.mem.readInt(u32, encoded[0..4], .little));
try std.testing.expectEqualSlices(u8, abp_line, encoded[4..]);
}
test "a paired corpus entry carries both lengths" {
const encoded = pairInput("*.a.b", "x.a.b");
try std.testing.expectEqual(@as(u32, 5), std.mem.readInt(u32, encoded[0..4], .little));
try std.testing.expectEqualSlices(u8, "*.a.b", encoded[4..9]);
try std.testing.expectEqual(@as(u32, 5), std.mem.readInt(u32, encoded[9..13], .little));
try std.testing.expectEqualSlices(u8, "x.a.b", encoded[13..]);
}
+6 -26
View File
@@ -30,7 +30,9 @@
const std = @import("std"); const std = @import("std");
const core = @import("core"); const core = @import("core");
const smith_encode = @import("smith_encode.zig");
const sliceInput = smith_encode.sliceInput;
const compiler = core.compiler; const compiler = core.compiler;
const Smith = std.testing.Smith; const Smith = std.testing.Smith;
@@ -132,11 +134,10 @@ fn expectConsistent(counts: compiler.Counts, bytes: []const u8) !void {
// corpus // corpus
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// //
// `Smith` does not consume a corpus entry as raw input. It reads a byte stream // `Smith` does not consume a corpus entry as raw input, so every entry below
// in which a slice is a little-endian `u32` length followed by that many bytes, // goes through the `smith_encode.zig` encoder. An entry that carries only the
// so every entry below is length-prefixed. An entry that carries only the slice // slice leaves the format index and the reader-buffer length at the low end of
// leaves the format index and the reader-buffer length at the low end of their // their ranges, which is the 64-byte buffer that makes `error.StreamTooLong` the
// ranges, which is the 64-byte buffer that makes `error.StreamTooLong` the
// common case. // common case.
/// Past `compiler.max_line_len`, so the discard arm at compiler.zig:72 replays /// Past `compiler.max_line_len`, so the discard arm at compiler.zig:72 replays
@@ -148,18 +149,6 @@ const long_line = "a" ** 5000 ++ ".example.com";
const long_line_unterminated = "0.0.0.0 kept.example.com\n" ++ long_line; const long_line_unterminated = "0.0.0.0 kept.example.com\n" ++ long_line;
const long_line_terminated = long_line_unterminated ++ "\n0.0.0.0 after.example.com\n"; const long_line_terminated = long_line_unterminated ++ "\n0.0.0.0 after.example.com\n";
/// Encodes `bytes` as a single `Smith.slice` value.
fn sliceInput(comptime bytes: []const u8) *const [4 + bytes.len]u8 {
return &struct {
const value: [4 + bytes.len]u8 = blk: {
var buf: [4 + bytes.len]u8 = undefined;
std.mem.writeInt(u32, buf[0..4], @intCast(bytes.len), .little);
buf[4..].* = bytes[0..bytes.len].*;
break :blk buf;
};
}.value;
}
const corpus = [_][]const u8{ const corpus = [_][]const u8{
sliceInput(long_line_unterminated), sliceInput(long_line_unterminated),
sliceInput(long_line_terminated), sliceInput(long_line_terminated),
@@ -173,12 +162,3 @@ test "the unterminated corpus entry ends on an over-long line" {
const last = std.mem.findScalarLast(u8, long_line_unterminated, '\n').? + 1; const last = std.mem.findScalarLast(u8, long_line_unterminated, '\n').? + 1;
try std.testing.expect(long_line_unterminated.len - last > compiler.max_line_len); try std.testing.expect(long_line_unterminated.len - last > compiler.max_line_len);
} }
test "a corpus entry carries its own length" {
const encoded = sliceInput(long_line);
try std.testing.expectEqual(
@as(u32, long_line.len),
std.mem.readInt(u32, encoded[0..4], .little),
);
try std.testing.expectEqualSlices(u8, long_line, encoded[4..]);
}
+10 -15
View File
@@ -14,6 +14,9 @@
const std = @import("std"); const std = @import("std");
const dns = @import("dns"); const dns = @import("dns");
const smith_encode = @import("smith_encode.zig");
const sliceInput = smith_encode.sliceInput;
/// A query for example.com A with an EDNS(0) OPT record advertising 4096 /// A query for example.com A with an EDNS(0) OPT record advertising 4096
/// bytes: id 0x1234, RD set, one question, one additional. /// bytes: id 0x1234, RD set, one question, one additional.
@@ -119,18 +122,6 @@ const max_jumps = dns.types.max_compression_jumps;
pub const chain_at_cap = pointerChain(max_jumps); pub const chain_at_cap = pointerChain(max_jumps);
pub const chain_past_cap = pointerChain(max_jumps + 1); pub const chain_past_cap = pointerChain(max_jumps + 1);
/// Encodes `bytes` as a single `Smith.slice` value.
fn sliceInput(comptime bytes: []const u8) *const [4 + bytes.len]u8 {
return &struct {
const value: [4 + bytes.len]u8 = blk: {
var buf: [4 + bytes.len]u8 = undefined;
std.mem.writeInt(u32, buf[0..4], @intCast(bytes.len), .little);
buf[4..].* = bytes[0..bytes.len].*;
break :blk buf;
};
}.value;
}
/// Encodes `bytes` as a `Smith.slice` value followed by one integer, which the /// Encodes `bytes` as a `Smith.slice` value followed by one integer, which the
/// name target reads as an offset and the TTL target as an elapsed time. /// name target reads as an offset and the TTL target as an elapsed time.
fn sliceIntInput(comptime bytes: []const u8, comptime int: u64) *const [12 + bytes.len]u8 { fn sliceIntInput(comptime bytes: []const u8, comptime int: u64) *const [12 + bytes.len]u8 {
@@ -181,8 +172,12 @@ test "the pointer chain has the documented shape" {
try std.testing.expectEqual(@as(u16, 0xc000), std.mem.readInt(u16, chain_at_cap[3..5], .big)); try std.testing.expectEqual(@as(u16, 0xc000), std.mem.readInt(u16, chain_at_cap[3..5], .big));
} }
test "a slice input carries its own length" { test "a slice-plus-integer input carries the length, the bytes and the integer" {
const encoded = sliceInput(query); const encoded = sliceIntInput(query, 29);
try std.testing.expectEqual(@as(u32, query.len), std.mem.readInt(u32, encoded[0..4], .little)); try std.testing.expectEqual(@as(u32, query.len), std.mem.readInt(u32, encoded[0..4], .little));
try std.testing.expectEqualSlices(u8, query, encoded[4..]); try std.testing.expectEqualSlices(u8, query, encoded[4 .. 4 + query.len]);
try std.testing.expectEqual(
@as(u64, 29),
std.mem.readInt(u64, encoded[4 + query.len ..][0..8], .little),
);
} }
+5 -45
View File
@@ -29,7 +29,10 @@
const std = @import("std"); const std = @import("std");
const http_util = @import("http_util"); const http_util = @import("http_util");
const smith_encode = @import("smith_encode.zig");
const sliceInput = smith_encode.sliceInput;
const pairInput = smith_encode.pairInput;
const Smith = std.testing.Smith; const Smith = std.testing.Smith;
/// A target longer than this is a 414 before it reaches any parser /// A target longer than this is a 414 before it reaches any parser
@@ -149,9 +152,8 @@ fn expectPrefixOf(result: []const u8, buffer: []const u8) !void {
// corpus // corpus
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// //
// `Smith` does not consume a corpus entry as raw parser input. It reads a byte // `Smith` does not consume a corpus entry as raw parser input, so every entry
// stream in which a slice is a little-endian `u32` length followed by that many // below goes through the `smith_encode.zig` encoders. The three targets share one
// bytes, so every entry below is length-prefixed. The three targets share one
// corpus: each starts with a slice, and the query target reads a second one that // corpus: each starts with a slice, and the query target reads a second one that
// falls back to empty when an entry carries only the first. // falls back to empty when an entry carries only the first.
@@ -169,31 +171,6 @@ const deep_path = "/1/2/3/4/5/6/7/8/9";
/// through, and a plus that means different things under the two rules. /// through, and a plus that means different things under the two rules.
const bad_escapes = "/%2/%/%zz/a+b"; const bad_escapes = "/%2/%/%zz/a+b";
/// Encodes `bytes` as a single `Smith.slice` value.
fn sliceInput(comptime bytes: []const u8) *const [4 + bytes.len]u8 {
return &struct {
const value: [4 + bytes.len]u8 = blk: {
var buf: [4 + bytes.len]u8 = undefined;
std.mem.writeInt(u32, buf[0..4], @intCast(bytes.len), .little);
buf[4..].* = bytes[0..bytes.len].*;
break :blk buf;
};
}.value;
}
/// Encodes two `Smith.slice` values back to back, which is what the query target
/// reads as its query string and its key.
fn pairInput(comptime a: []const u8, comptime b: []const u8) *const [8 + a.len + b.len]u8 {
return &struct {
const value: [8 + a.len + b.len]u8 = blk: {
var buf: [8 + a.len + b.len]u8 = undefined;
buf[0 .. 4 + a.len].* = sliceInput(a).*;
buf[4 + a.len ..].* = sliceInput(b).*;
break :blk buf;
};
}.value;
}
const corpus = [_][]const u8{ const corpus = [_][]const u8{
sliceInput(api_path), sliceInput(api_path),
sliceInput(encoded_slash), sliceInput(encoded_slash),
@@ -207,20 +184,3 @@ const corpus = [_][]const u8{
pairInput("domain=" ++ "x" ** 1024, "domain"), pairInput("domain=" ++ "x" ** 1024, "domain"),
pairInput("domain=%zz", "domain"), pairInput("domain=%zz", "domain"),
}; };
test "a corpus entry carries its own length" {
const encoded = sliceInput(api_path);
try std.testing.expectEqual(
@as(u32, api_path.len),
std.mem.readInt(u32, encoded[0..4], .little),
);
try std.testing.expectEqualSlices(u8, api_path, encoded[4..]);
}
test "a paired corpus entry carries both lengths" {
const encoded = pairInput("a=1", "a");
try std.testing.expectEqual(@as(u32, 3), std.mem.readInt(u32, encoded[0..4], .little));
try std.testing.expectEqualSlices(u8, "a=1", encoded[4..7]);
try std.testing.expectEqual(@as(u32, 1), std.mem.readInt(u32, encoded[7..11], .little));
try std.testing.expectEqualSlices(u8, "a", encoded[11..]);
}
+64
View File
@@ -0,0 +1,64 @@
//! The `std.testing.Smith` byte encoding, shared by every fuzz corpus.
//!
//! `Smith` does not consume a corpus entry as raw parser input. It reads a byte
//! stream in which a slice is a little-endian `u32` length followed by that many
//! bytes, and an integer is a little-endian `u64`. Every corpus entry in
//! `tests/fuzz/` is therefore length-prefixed, and every fuzz file used to spell
//! the same encoder out.
//!
//! This file imports nothing but `std` on purpose. The fuzz targets root
//! separate modules over different parts of `src/` — `blocklist_fuzz.zig` cannot
//! import `corpus.zig`, because `corpus.zig` needs the `dns` module that the
//! blocklist target's build does not have. Each fuzz module reaches this file by
//! relative path and compiles its own copy, so the dedup is at the source level;
//! the self-tests below run once per fuzz artifact.
const std = @import("std");
/// Encodes `bytes` as a single `Smith.slice` value.
pub fn sliceInput(comptime bytes: []const u8) *const [4 + bytes.len]u8 {
return &struct {
const value: [4 + bytes.len]u8 = blk: {
var buf: [4 + bytes.len]u8 = undefined;
std.mem.writeInt(u32, buf[0..4], @intCast(bytes.len), .little);
buf[4..].* = bytes[0..bytes.len].*;
break :blk buf;
};
}.value;
}
/// Encodes two `Smith.slice` values back to back, which is what a target that
/// reads two entities takes: the pattern and the domain, or the query string and
/// the key.
pub fn pairInput(comptime a: []const u8, comptime b: []const u8) *const [8 + a.len + b.len]u8 {
return &struct {
const value: [8 + a.len + b.len]u8 = blk: {
var buf: [8 + a.len + b.len]u8 = undefined;
buf[0 .. 4 + a.len].* = sliceInput(a).*;
buf[4 + a.len ..].* = sliceInput(b).*;
break :blk buf;
};
}.value;
}
test "a slice input carries its own length" {
const encoded = sliceInput("ads.example.com");
try std.testing.expectEqual(
@as(u32, "ads.example.com".len),
std.mem.readInt(u32, encoded[0..4], .little),
);
try std.testing.expectEqualSlices(u8, "ads.example.com", encoded[4..]);
}
test "a paired input carries both lengths" {
const encoded = pairInput("*.a.b", "x.a.b");
try std.testing.expectEqual(@as(u32, 5), std.mem.readInt(u32, encoded[0..4], .little));
try std.testing.expectEqualSlices(u8, "*.a.b", encoded[4..9]);
try std.testing.expectEqual(@as(u32, 5), std.mem.readInt(u32, encoded[9..13], .little));
try std.testing.expectEqualSlices(u8, "x.a.b", encoded[13..]);
}
test "an empty slice input is four bytes of zero" {
const encoded = sliceInput("");
try std.testing.expectEqualSlices(u8, &.{ 0, 0, 0, 0 }, encoded);
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useState, type FormEvent } from "react";
import { useRouter, useSearch } from "@tanstack/react-router"; import { useRouter, useSearch } from "@tanstack/react-router";
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
import { useAuth } from "@/auth/store"; import { useAuth } from "@/auth/store";
import { inputClass, largePrimaryButtonClass } from "@/ui/classes";
export function safeRedirect(raw: string | undefined): string { export function safeRedirect(raw: string | undefined): string {
if (raw === undefined) return "/"; if (raw === undefined) return "/";
@@ -94,13 +95,13 @@ export default function LoginPage() {
required required
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
className="mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900" className={inputClass}
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={busy || lockedOut} disabled={busy || lockedOut}
className="w-full rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" className={`w-full ${largePrimaryButtonClass}`}
> >
{busy ? "Logging in…" : "Log in"} {busy ? "Logging in…" : "Log in"}
</button> </button>
+11 -16
View File
@@ -1,9 +1,7 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import type { Blocklist, BlocklistInput } from "@/lib/types"; import type { Blocklist, BlocklistInput } from "@/lib/types";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
const INPUT_CLASS =
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
interface BlocklistFormProps { interface BlocklistFormProps {
initial?: Blocklist; initial?: Blocklist;
@@ -45,7 +43,7 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
required required
value={url} value={url}
onChange={(event) => setUrl(event.target.value)} onChange={(event) => setUrl(event.target.value)}
className={INPUT_CLASS} className={inputClass}
/> />
</div> </div>
<div> <div>
@@ -58,27 +56,24 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
required required
value={name} value={name}
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
className={INPUT_CLASS} className={inputClass}
/> />
</div> </div>
<label className="flex items-center gap-2 text-sm font-medium"> <label className="flex items-center gap-2 text-sm font-medium">
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} /> <input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
className={focusRing}
/>
Enabled Enabled
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button type="submit" disabled={busy} className={primaryButtonClass}>
type="submit"
disabled={busy}
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
{initial === undefined ? "Add source" : "Save changes"} {initial === undefined ? "Add source" : "Save changes"}
</button> </button>
{onCancel !== undefined && ( {onCancel !== undefined && (
<button <button type="button" onClick={onCancel} className={`${buttonClass} font-medium`}>
type="button"
onClick={onCancel}
className="rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
>
Cancel Cancel
</button> </button>
)} )}
+30 -23
View File
@@ -13,9 +13,15 @@ import {
import type { Blocklist, BlocklistInput, SourceStatus } from "@/lib/types"; import type { Blocklist, BlocklistInput, SourceStatus } from "@/lib/types";
import BlocklistForm from "./BlocklistForm"; import BlocklistForm from "./BlocklistForm";
import SourceStatusSection from "./SourceStatusSection"; import SourceStatusSection from "./SourceStatusSection";
import {
const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700"; dangerLinkButtonClass,
const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800"; focusRing,
linkButtonClass,
primaryButtonClass,
tableWrapClass,
tdClass,
thClass,
} from "@/ui/classes";
export default function BlocklistsPage() { export default function BlocklistsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -66,7 +72,7 @@ export default function BlocklistsPage() {
type="button" type="button"
onClick={() => updateNow.mutate()} onClick={() => updateNow.mutate()}
disabled={updateNow.isPending} disabled={updateNow.isPending}
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" className={primaryButtonClass}
> >
{updateNow.isPending ? "Updating…" : "Update now"} {updateNow.isPending ? "Updating…" : "Update now"}
</button> </button>
@@ -81,18 +87,18 @@ export default function BlocklistsPage() {
{blocklists.length === 0 ? ( {blocklists.length === 0 ? (
<p className="mt-4 text-zinc-500">No blocklist sources yet. Add one below.</p> <p className="mt-4 text-zinc-500">No blocklist sources yet. Add one below.</p>
) : ( ) : (
<div className="mt-4 overflow-x-auto"> <div className={tableWrapClass}>
<table className="w-full min-w-max border-collapse text-sm"> <table className="w-full min-w-max border-collapse text-sm">
<thead> <thead>
<tr> <tr>
<th className={TH_CLASS}>Name</th> <th className={thClass}>Name</th>
<th className={TH_CLASS}>URL</th> <th className={thClass}>URL</th>
<th className={TH_CLASS}>Enabled</th> <th className={thClass}>Enabled</th>
<th className={TH_CLASS}>Domains</th> <th className={thClass}>Domains</th>
<th className={TH_CLASS}>Wildcards</th> <th className={thClass}>Wildcards</th>
<th className={TH_CLASS}>Skipped regex</th> <th className={thClass}>Skipped regex</th>
<th className={TH_CLASS}>Last updated</th> <th className={thClass}>Last updated</th>
<th className={TH_CLASS}> <th className={thClass}>
<span className="sr-only">Actions</span> <span className="sr-only">Actions</span>
</th> </th>
</tr> </tr>
@@ -100,7 +106,7 @@ export default function BlocklistsPage() {
<tbody> <tbody>
{blocklists.map((b) => ( {blocklists.map((b) => (
<tr key={b.id}> <tr key={b.id}>
<td className={TD_CLASS}> <td className={tdClass}>
<span className="font-medium">{b.name}</span> <span className="font-medium">{b.name}</span>
{b.is_suggested && ( {b.is_suggested && (
<span className="ml-2 rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"> <span className="ml-2 rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
@@ -108,32 +114,33 @@ export default function BlocklistsPage() {
</span> </span>
)} )}
</td> </td>
<td className={TD_CLASS}> <td className={tdClass}>
<span className="block max-w-72 truncate" title={b.url}> <span className="block max-w-72 truncate" title={b.url}>
{b.url} {b.url}
</span> </span>
</td> </td>
<td className={TD_CLASS}> <td className={tdClass}>
<input <input
type="checkbox" type="checkbox"
aria-label={`${b.name} enabled`} aria-label={`${b.name} enabled`}
checked={b.enabled} checked={b.enabled}
disabled={toggle.isPending} disabled={toggle.isPending}
onChange={() => toggleEnabled(b)} onChange={() => toggleEnabled(b)}
className={focusRing}
/> />
</td> </td>
<td className={`${TD_CLASS} tabular-nums`}>{b.domain_count}</td> <td className={`${tdClass} tabular-nums`}>{b.domain_count}</td>
<td className={`${TD_CLASS} tabular-nums`}>{b.wildcard_count}</td> <td className={`${tdClass} tabular-nums`}>{b.wildcard_count}</td>
<td className={`${TD_CLASS} tabular-nums`}>{b.skipped_regex_count}</td> <td className={`${tdClass} tabular-nums`}>{b.skipped_regex_count}</td>
<td className={TD_CLASS}> <td className={tdClass}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)} {b.last_updated === null ? "never" : formatTime(b.last_updated)}
</td> </td>
<td className={TD_CLASS}> <td className={tdClass}>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
type="button" type="button"
onClick={() => setEditing(b)} onClick={() => setEditing(b)}
className="text-sm font-medium text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400" className={linkButtonClass}
> >
Edit Edit
</button> </button>
@@ -141,7 +148,7 @@ export default function BlocklistsPage() {
type="button" type="button"
onClick={() => deleteBlocklist(b)} onClick={() => deleteBlocklist(b)}
disabled={remove.isPending} disabled={remove.isPending}
className="text-sm font-medium text-red-600 disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-red-400" className={dangerLinkButtonClass}
> >
Delete Delete
</button> </button>
@@ -1,8 +1,6 @@
import { formatTime } from "@/lib/format"; import { formatTime } from "@/lib/format";
import type { SourceStatus } from "@/lib/types"; import type { SourceStatus } from "@/lib/types";
import { tdClass, thClass } from "@/ui/classes";
const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
function formatAttempt(unixSeconds: number): string { function formatAttempt(unixSeconds: number): string {
return unixSeconds === 0 ? "never" : formatTime(unixSeconds); return unixSeconds === 0 ? "never" : formatTime(unixSeconds);
@@ -28,26 +26,26 @@ export default function SourceStatusSection({ sources, namesById }: SourceStatus
<table className="w-full min-w-max border-collapse text-sm"> <table className="w-full min-w-max border-collapse text-sm">
<thead> <thead>
<tr> <tr>
<th className={TH_CLASS}>Source</th> <th className={thClass}>Source</th>
<th className={TH_CLASS}>State</th> <th className={thClass}>State</th>
<th className={TH_CLASS}>Last attempt</th> <th className={thClass}>Last attempt</th>
<th className={TH_CLASS}>Last success</th> <th className={thClass}>Last success</th>
<th className={TH_CLASS}>Domains</th> <th className={thClass}>Domains</th>
<th className={TH_CLASS}>Wildcards</th> <th className={thClass}>Wildcards</th>
<th className={TH_CLASS}>Skipped regex</th> <th className={thClass}>Skipped regex</th>
<th className={TH_CLASS}>Last error</th> <th className={thClass}>Last error</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sources.map((source) => ( {sources.map((source) => (
<tr key={source.id}> <tr key={source.id}>
<td className={TD_CLASS}> <td className={tdClass}>
<span className="font-medium">{namesById.get(source.id) ?? source.url}</span> <span className="font-medium">{namesById.get(source.id) ?? source.url}</span>
<span className="mt-0.5 block max-w-64 truncate text-xs text-zinc-500"> <span className="mt-0.5 block max-w-64 truncate text-xs text-zinc-500">
{source.url} {source.url}
</span> </span>
</td> </td>
<td className={TD_CLASS}> <td className={tdClass}>
<span <span
className={ className={
source.loaded source.loaded
@@ -58,12 +56,12 @@ export default function SourceStatusSection({ sources, namesById }: SourceStatus
{source.state} {source.state}
</span> </span>
</td> </td>
<td className={TD_CLASS}>{formatAttempt(source.last_attempt)}</td> <td className={tdClass}>{formatAttempt(source.last_attempt)}</td>
<td className={TD_CLASS}>{formatAttempt(source.last_success)}</td> <td className={tdClass}>{formatAttempt(source.last_success)}</td>
<td className={`${TD_CLASS} tabular-nums`}>{source.domains}</td> <td className={`${tdClass} tabular-nums`}>{source.domains}</td>
<td className={`${TD_CLASS} tabular-nums`}>{source.wildcards}</td> <td className={`${tdClass} tabular-nums`}>{source.wildcards}</td>
<td className={`${TD_CLASS} tabular-nums`}>{source.skipped_regex}</td> <td className={`${tdClass} tabular-nums`}>{source.skipped_regex}</td>
<td className={TD_CLASS}> <td className={tdClass}>
{source.last_error === "" ? ( {source.last_error === "" ? (
<span className="text-zinc-400"></span> <span className="text-zinc-400"></span>
) : ( ) : (
+6 -14
View File
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { clientUpdateMutation } from "@/lib/queries"; import { clientUpdateMutation } from "@/lib/queries";
import type { Client, Group } from "@/lib/types"; import type { Client, Group } from "@/lib/types";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { buttonClass, primaryButtonClass, smallInputClass } from "@/ui/classes";
interface Props { interface Props {
client: Client; client: Client;
@@ -10,8 +11,7 @@ interface Props {
onClose: () => void; onClose: () => void;
} }
const inputClass = const dialogInputClass = `mt-1 w-full ${smallInputClass}`;
"mt-1 w-full rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900";
export default function ClientEditDialog({ client, groups, onClose }: Props) { export default function ClientEditDialog({ client, groups, onClose }: Props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -44,7 +44,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
type="text" type="text"
value={name} value={name}
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
className={inputClass} className={dialogInputClass}
autoFocus autoFocus
/> />
</label> </label>
@@ -53,7 +53,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
<select <select
value={String(groupId)} value={String(groupId)}
onChange={(event) => setGroupId(Number(event.target.value))} onChange={(event) => setGroupId(Number(event.target.value))}
className={inputClass} className={dialogInputClass}
> >
{groups.map((group) => ( {groups.map((group) => (
<option key={group.id} value={String(group.id)}> <option key={group.id} value={String(group.id)}>
@@ -64,18 +64,10 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
</label> </label>
<InlineError error={mutation.error} /> <InlineError error={mutation.error} />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button type="button" onClick={onClose} className={buttonClass}>
type="button"
onClick={onClose}
className="rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
>
Cancel Cancel
</button> </button>
<button <button type="submit" disabled={mutation.isPending} className={primaryButtonClass}>
type="submit"
disabled={mutation.isPending}
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Save Save
</button> </button>
</div> </div>
+6 -7
View File
@@ -6,10 +6,9 @@ import type { Client } from "@/lib/types";
import ClientEditDialog from "./ClientEditDialog"; import ClientEditDialog from "./ClientEditDialog";
import PrefixesEditor from "./PrefixesEditor"; import PrefixesEditor from "./PrefixesEditor";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { smallButtonClass, tableWrapClass } from "@/ui/classes";
const cellClass = "px-3 py-2"; const cellClass = "px-3 py-2";
const buttonClass =
"rounded border border-zinc-300 px-2 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
export default function ClientsPage() { export default function ClientsPage() {
const { data: clients } = useSuspenseQuery(clientsQuery()); const { data: clients } = useSuspenseQuery(clientsQuery());
@@ -29,7 +28,7 @@ export default function ClientsPage() {
nothing to create by hand. nothing to create by hand.
</p> </p>
) : ( ) : (
<div className="mt-4 overflow-x-auto"> <div className={tableWrapClass}>
<table className="w-full min-w-[48rem] text-left text-sm"> <table className="w-full min-w-[48rem] text-left text-sm">
<thead> <thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-700"> <tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-700">
@@ -70,14 +69,14 @@ export default function ClientsPage() {
setConfirmingId(null); setConfirmingId(null);
deleteMutation.mutate(client.id); deleteMutation.mutate(client.id);
}} }}
className={`${buttonClass} text-red-700 dark:text-red-400`} className={`${smallButtonClass} text-red-700 dark:text-red-400`}
> >
Confirm delete Confirm delete
</button> </button>
<button <button
type="button" type="button"
onClick={() => setConfirmingId(null)} onClick={() => setConfirmingId(null)}
className={buttonClass} className={smallButtonClass}
> >
Cancel Cancel
</button> </button>
@@ -87,14 +86,14 @@ export default function ClientsPage() {
<button <button
type="button" type="button"
onClick={() => setEditing(client)} onClick={() => setEditing(client)}
className={buttonClass} className={smallButtonClass}
> >
Edit Edit
</button> </button>
<button <button
type="button" type="button"
onClick={() => setConfirmingId(client.id)} onClick={() => setConfirmingId(client.id)}
className={`${buttonClass} text-red-700 dark:text-red-400`} className={`${smallButtonClass} text-red-700 dark:text-red-400`}
> >
Delete Delete
</button> </button>
+8 -9
View File
@@ -4,14 +4,13 @@ import { clientPrefixesPutMutation } from "@/lib/queries";
import type { ClientPrefix, Group } from "@/lib/types"; import type { ClientPrefix, Group } from "@/lib/types";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor"; import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes";
interface Props { interface Props {
prefixes: ClientPrefix[]; prefixes: ClientPrefix[];
groups: Group[]; groups: Group[];
} }
const inputClass = "rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900";
export default function PrefixesEditor({ prefixes, groups }: Props) { export default function PrefixesEditor({ prefixes, groups }: Props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutation = useMutation(clientPrefixesPutMutation(queryClient)); const mutation = useMutation(clientPrefixesPutMutation(queryClient));
@@ -50,7 +49,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) => onChange={(event) =>
dispatch({ type: "edit", index, patch: { prefix: event.target.value } }) dispatch({ type: "edit", index, patch: { prefix: event.target.value } })
} }
className={`${inputClass} w-52`} className={`${smallInputClass} w-52`}
/> />
<select <select
aria-label={`Group for prefix ${index + 1}`} aria-label={`Group for prefix ${index + 1}`}
@@ -58,7 +57,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) => onChange={(event) =>
dispatch({ type: "edit", index, patch: { group_id: Number(event.target.value) } }) dispatch({ type: "edit", index, patch: { group_id: Number(event.target.value) } })
} }
className={inputClass} className={smallInputClass}
> >
{groups.map((group) => ( {groups.map((group) => (
<option key={group.id} value={String(group.id)}> <option key={group.id} value={String(group.id)}>
@@ -75,12 +74,12 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) => onChange={(event) =>
dispatch({ type: "edit", index, patch: { priority: event.target.value } }) dispatch({ type: "edit", index, patch: { priority: event.target.value } })
} }
className={`${inputClass} w-20`} className={`${smallInputClass} w-20`}
/> />
<button <button
type="button" type="button"
onClick={() => dispatch({ type: "remove", index })} onClick={() => dispatch({ type: "remove", index })}
className="rounded border border-zinc-300 px-2 py-1.5 text-sm text-red-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:text-red-400" className={`rounded border border-zinc-300 px-2 py-1.5 text-sm text-red-700 ${focusRing} dark:border-zinc-700 dark:text-red-400`}
> >
Remove Remove
</button> </button>
@@ -98,7 +97,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
<button <button
type="button" type="button"
onClick={() => dispatch({ type: "add", groupId: defaultGroupId })} onClick={() => dispatch({ type: "add", groupId: defaultGroupId })}
className="rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700" className={buttonClass}
> >
Add prefix Add prefix
</button> </button>
@@ -106,7 +105,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
type="button" type="button"
onClick={save} onClick={save}
disabled={!dirty || mutation.isPending} disabled={!dirty || mutation.isPending}
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" className={primaryButtonClass}
> >
Save prefixes Save prefixes
</button> </button>
@@ -117,7 +116,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
setValidation(null); setValidation(null);
dispatch({ type: "reset", prefixes }); dispatch({ type: "reset", prefixes });
}} }}
className="rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700" className={buttonClass}
> >
Discard changes Discard changes
</button> </button>
+3 -14
View File
@@ -1,8 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { ApiError } from "@/lib/api";
import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries"; import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries";
import type { Period } from "@/lib/types"; import type { Period } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import { focusRing } from "@/ui/classes";
import DiskCard from "./DiskCard"; import DiskCard from "./DiskCard";
import HealthBanners from "./HealthBanners"; import HealthBanners from "./HealthBanners";
import StatCards from "./StatCards"; import StatCards from "./StatCards";
@@ -20,7 +21,7 @@ function PeriodPicker({ period, onChange }: { period: Period; onChange: (period:
type="button" type="button"
aria-pressed={option === period} aria-pressed={option === period}
onClick={() => onChange(option)} onClick={() => onChange(option)}
className={`rounded px-2.5 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 ${ className={`rounded px-2.5 py-1 text-sm ${focusRing} ${
option === period option === period
? "bg-zinc-200 font-medium text-zinc-900 dark:bg-zinc-700 dark:text-zinc-50" ? "bg-zinc-200 font-medium text-zinc-900 dark:bg-zinc-700 dark:text-zinc-50"
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800" : "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800"
@@ -33,18 +34,6 @@ function PeriodPicker({ period, onChange }: { period: Period; onChange: (period:
); );
} }
function InlineError({ error, onRetry }: { error: unknown; onRetry: () => void }) {
const message = error instanceof ApiError ? error.message : "request failed";
return (
<div className="rounded border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-200">
Failed to load: {message}{" "}
<button type="button" onClick={onRetry} className="font-medium underline">
Retry
</button>
</div>
);
}
function Skeleton({ height }: { height: number }) { function Skeleton({ height }: { height: number }) {
return <div aria-hidden="true" className="animate-pulse rounded bg-zinc-200 dark:bg-zinc-800" style={{ height }} />; return <div aria-hidden="true" className="animate-pulse rounded bg-zinc-200 dark:bg-zinc-800" style={{ height }} />;
} }
@@ -4,6 +4,7 @@ import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
import type { Blocklist } from "@/lib/types"; import type { Blocklist } from "@/lib/types";
import { sameSet, toggleSource } from "./sourceSet"; import { sameSet, toggleSource } from "./sourceSet";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass } from "@/ui/classes";
interface Props { interface Props {
groupId: number; groupId: number;
@@ -46,6 +47,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
type="checkbox" type="checkbox"
checked={current.includes(blocklist.id)} checked={current.includes(blocklist.id)}
onChange={() => setSelected(toggleSource(current, blocklist.id))} onChange={() => setSelected(toggleSource(current, blocklist.id))}
className={focusRing}
/> />
{blocklist.name} {blocklist.name}
</label> </label>
@@ -60,16 +62,12 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
onClick={() => onClick={() =>
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) }) mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
} }
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" className={primaryButtonClass}
> >
Save sources Save sources
</button> </button>
{dirty && ( {dirty && (
<button <button type="button" onClick={() => setSelected(null)} className={buttonClass}>
type="button"
onClick={() => setSelected(null)}
className="rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
>
Discard Discard
</button> </button>
)} )}
+12 -14
View File
@@ -10,15 +10,12 @@ import {
import type { Blocklist, Group } from "@/lib/types"; import type { Blocklist, Group } from "@/lib/types";
import GroupSourcesEditor from "./GroupSourcesEditor"; import GroupSourcesEditor from "./GroupSourcesEditor";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { focusRing, primaryButtonClass, smallButtonClass, smallInputClass } from "@/ui/classes";
const DEFAULT_GROUP_ID = 1; const DEFAULT_GROUP_ID = 1;
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted."; const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
const inputClass = "rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900"; const groupButtonClass = `${smallButtonClass} disabled:opacity-50`;
const buttonClass =
"rounded border border-zinc-300 px-2 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 dark:border-zinc-700";
const primaryButtonClass =
"rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
export default function GroupsPage() { export default function GroupsPage() {
const { data: groups } = useSuspenseQuery(groupsQuery()); const { data: groups } = useSuspenseQuery(groupsQuery());
@@ -47,7 +44,7 @@ export default function GroupsPage() {
type="text" type="text"
value={newName} value={newName}
onChange={(event) => setNewName(event.target.value)} onChange={(event) => setNewName(event.target.value)}
className={inputClass} className={smallInputClass}
/> />
<button type="submit" disabled={createMutation.isPending} className={primaryButtonClass}> <button type="submit" disabled={createMutation.isPending} className={primaryButtonClass}>
Create Create
@@ -94,10 +91,10 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
aria-label={`New name for ${group.name}`} aria-label={`New name for ${group.name}`}
value={name} value={name}
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
className={inputClass} className={smallInputClass}
autoFocus autoFocus
/> />
<button type="submit" disabled={updateMutation.isPending} className={buttonClass}> <button type="submit" disabled={updateMutation.isPending} className={groupButtonClass}>
Save Save
</button> </button>
<button <button
@@ -106,7 +103,7 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
setName(group.name); setName(group.name);
setRenaming(false); setRenaming(false);
}} }}
className={buttonClass} className={groupButtonClass}
> >
Cancel Cancel
</button> </button>
@@ -119,6 +116,7 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
type="checkbox" type="checkbox"
checked={group.safe_search} checked={group.safe_search}
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
className={focusRing}
onChange={(event) => onChange={(event) =>
updateMutation.mutate({ updateMutation.mutate({
id: group.id, id: group.id,
@@ -133,7 +131,7 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
type="button" type="button"
aria-expanded={expanded} aria-expanded={expanded}
onClick={() => setExpanded((open) => !open)} onClick={() => setExpanded((open) => !open)}
className={buttonClass} className={groupButtonClass}
> >
Sources Sources
</button> </button>
@@ -146,7 +144,7 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
setName(group.name); setName(group.name);
setRenaming(true); setRenaming(true);
}} }}
className={buttonClass} className={groupButtonClass}
> >
Rename Rename
</button> </button>
@@ -159,11 +157,11 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
setConfirming(false); setConfirming(false);
deleteMutation.mutate(group.id); deleteMutation.mutate(group.id);
}} }}
className={`${buttonClass} text-red-700 dark:text-red-400`} className={`${groupButtonClass} text-red-700 dark:text-red-400`}
> >
Confirm delete Confirm delete
</button> </button>
<button type="button" onClick={() => setConfirming(false)} className={buttonClass}> <button type="button" onClick={() => setConfirming(false)} className={groupButtonClass}>
Cancel Cancel
</button> </button>
</> </>
@@ -173,7 +171,7 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
disabled={isDefault} disabled={isDefault}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined} title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
onClick={() => setConfirming(true)} onClick={() => setConfirming(true)}
className={`${buttonClass} text-red-700 dark:text-red-400`} className={`${groupButtonClass} text-red-700 dark:text-red-400`}
> >
Delete Delete
</button> </button>
+10 -9
View File
@@ -1,9 +1,9 @@
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage"; import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
import { RING_CAPACITY } from "./ringBuffer"; import { RING_CAPACITY } from "./ringBuffer";
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries"; import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
import { buttonClass, retryButtonClass, tableWrapClass } from "@/ui/classes";
const buttonClass = const toolbarButtonClass = `${buttonClass} font-medium`;
"rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
function StatusPill({ status }: { status: StreamStatus }) { function StatusPill({ status }: { status: StreamStatus }) {
const styles: Record<StreamStatus, [string, string]> = { const styles: Record<StreamStatus, [string, string]> = {
@@ -30,7 +30,12 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold">Live</h1> <h1 className="text-2xl font-semibold">Live</h1>
<StatusPill status={live.status} /> <StatusPill status={live.status} />
<button type="button" onClick={live.toggleFreeze} aria-pressed={live.frozen} className={buttonClass}> <button
type="button"
onClick={live.toggleFreeze}
aria-pressed={live.frozen}
className={toolbarButtonClass}
>
{live.frozen ? "Resume" : "Freeze"} {live.frozen ? "Resume" : "Freeze"}
</button> </button>
</div> </div>
@@ -73,11 +78,7 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
The connection failed repeatedly possibly too many live viewers (the server caps streams per The connection failed repeatedly possibly too many live viewers (the server caps streams per
address), or the server is unreachable. address), or the server is unreachable.
</p> </p>
<button <button type="button" onClick={live.retry} className={retryButtonClass}>
type="button"
onClick={live.retry}
className="mt-3 rounded border border-red-300 px-3 py-1.5 text-sm font-medium text-red-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-red-800 dark:text-red-200"
>
Retry Retry
</button> </button>
</div> </div>
@@ -91,7 +92,7 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
) )
) : ( ) : (
<> <>
<div className="mt-4 overflow-x-auto rounded border border-zinc-200 dark:border-zinc-800"> <div className={`${tableWrapClass} rounded border border-zinc-200 dark:border-zinc-800`}>
<table className="w-full text-sm"> <table className="w-full text-sm">
<QueryTableHead /> <QueryTableHead />
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800"> <tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
+2 -1
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import RecordsTab from "@/features/local/RecordsTab"; import RecordsTab from "@/features/local/RecordsTab";
import ZonesTab from "@/features/local/ZonesTab"; import ZonesTab from "@/features/local/ZonesTab";
import { focusRing } from "@/ui/classes";
type Tab = "records" | "zones"; type Tab = "records" | "zones";
@@ -25,7 +26,7 @@ function TabButton({
aria-controls={controls} aria-controls={controls}
aria-selected={selected} aria-selected={selected}
onClick={onClick} onClick={onClick}
className={`-mb-px border-b-2 px-3 py-2 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 ${ className={`-mb-px border-b-2 px-3 py-2 font-medium ${focusRing} ${
selected selected
? "border-blue-600 text-blue-600 dark:text-blue-400" ? "border-blue-600 text-blue-600 dark:text-blue-400"
: "border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" : "border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
+28 -54
View File
@@ -1,5 +1,5 @@
import { useId, useState, type FormEvent } from "react"; import { useId, useState, type FormEvent } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import { import {
localRecordCreateMutation, localRecordCreateMutation,
localRecordDeleteMutation, localRecordDeleteMutation,
@@ -8,20 +8,18 @@ import {
} from "@/lib/queries"; } from "@/lib/queries";
import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types"; import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { useCrudForm } from "@/ui/useCrudForm";
import {
formCardClass,
inputClass,
largeButtonClass,
largePrimaryButtonClass,
rowButtonClass,
tableWrapClass,
} from "@/ui/classes";
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"]; const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
const inputClass =
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
const primaryButtonClass =
"rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
const secondaryButtonClass =
"rounded border border-zinc-300 px-3 py-2 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
const rowButtonClass =
"rounded px-2 py-1 text-sm text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400";
type FormState = { mode: "create" } | { mode: "edit"; record: LocalRecord };
function RecordForm({ function RecordForm({
initial, initial,
busy, busy,
@@ -49,10 +47,7 @@ function RecordForm({
} }
return ( return (
<form <form onSubmit={submit} className={formCardClass}>
onSubmit={submit}
className="mt-4 max-w-lg space-y-3 rounded border border-zinc-200 p-4 dark:border-zinc-800"
>
<h3 className="font-medium">{initial === undefined ? "New record" : `Edit ${initial.name}`}</h3> <h3 className="font-medium">{initial === undefined ? "New record" : `Edit ${initial.name}`}</h3>
<div> <div>
<label htmlFor={`${id}-name`} className="block text-sm font-medium"> <label htmlFor={`${id}-name`} className="block text-sm font-medium">
@@ -114,10 +109,10 @@ function RecordForm({
/> />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button type="submit" disabled={busy} className={primaryButtonClass}> <button type="submit" disabled={busy} className={largePrimaryButtonClass}>
{busy ? "Saving…" : "Save"} {busy ? "Saving…" : "Save"}
</button> </button>
<button type="button" onClick={onCancel} className={secondaryButtonClass}> <button type="button" onClick={onCancel} className={largeButtonClass}>
Cancel Cancel
</button> </button>
</div> </div>
@@ -128,50 +123,29 @@ function RecordForm({
export default function RecordsTab() { export default function RecordsTab() {
const records = useSuspenseQuery(localRecordsQuery()).data; const records = useSuspenseQuery(localRecordsQuery()).data;
const queryClient = useQueryClient(); const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm<
const create = useMutation(localRecordCreateMutation(queryClient)); LocalRecord,
const update = useMutation(localRecordUpdateMutation(queryClient)); LocalRecordInput
const remove = useMutation(localRecordDeleteMutation(queryClient)); >({
const [form, setForm] = useState<FormState | null>(null); create: localRecordCreateMutation,
update: localRecordUpdateMutation,
function openForm(next: FormState) { remove: localRecordDeleteMutation,
create.reset(); confirmDelete: (record) => `Delete record "${record.name}"?`,
update.reset(); });
setForm(next);
}
function onSubmit(input: LocalRecordInput) {
if (form === null) return;
if (form.mode === "create") {
create.mutate(input, { onSuccess: () => setForm(null) });
} else {
update.mutate({ id: form.record.id, input }, { onSuccess: () => setForm(null) });
}
}
function onDelete(record: LocalRecord) {
if (!window.confirm(`Delete record "${record.name}"?`)) return;
remove.mutate(record.id);
}
return ( return (
<div> <div>
<div className="mt-4 flex items-center justify-between"> <div className="mt-4 flex items-center justify-between">
<p className="text-sm text-zinc-500">Answers served directly for LAN names. Changes apply live.</p> <p className="text-sm text-zinc-500">Answers served directly for LAN names. Changes apply live.</p>
<button type="button" onClick={() => openForm({ mode: "create" })} className={primaryButtonClass}> <button type="button" onClick={() => openForm({ mode: "create" })} className={largePrimaryButtonClass}>
Add record Add record
</button> </button>
</div> </div>
<InlineError error={remove.error} /> <InlineError error={remove.error} />
{form?.mode === "create" && ( {form?.mode === "create" && (
<RecordForm <RecordForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
busy={create.isPending}
error={create.error}
onSubmit={onSubmit}
onCancel={() => setForm(null)}
/>
)} )}
<div className="mt-4 overflow-x-auto"> <div className={tableWrapClass}>
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead> <thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800"> <tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
@@ -202,14 +176,14 @@ export default function RecordsTab() {
)} )}
{records.map((record) => ( {records.map((record) => (
<tr key={record.id} className="border-b border-zinc-100 dark:border-zinc-900"> <tr key={record.id} className="border-b border-zinc-100 dark:border-zinc-900">
{form?.mode === "edit" && form.record.id === record.id ? ( {form?.mode === "edit" && form.entity.id === record.id ? (
<td colSpan={5}> <td colSpan={5}>
<RecordForm <RecordForm
initial={record} initial={record}
busy={update.isPending} busy={update.isPending}
error={update.error} error={update.error}
onSubmit={onSubmit} onSubmit={onSubmit}
onCancel={() => setForm(null)} onCancel={closeForm}
/> />
</td> </td>
) : ( ) : (
@@ -221,7 +195,7 @@ export default function RecordsTab() {
<td className="py-2 text-right whitespace-nowrap"> <td className="py-2 text-right whitespace-nowrap">
<button <button
type="button" type="button"
onClick={() => openForm({ mode: "edit", record })} onClick={() => openForm({ mode: "edit", entity: record })}
className={rowButtonClass} className={rowButtonClass}
> >
Edit Edit
+28 -54
View File
@@ -1,5 +1,5 @@
import { useId, useState, type FormEvent } from "react"; import { useId, useState, type FormEvent } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import { import {
forwardZoneCreateMutation, forwardZoneCreateMutation,
forwardZoneDeleteMutation, forwardZoneDeleteMutation,
@@ -8,17 +8,15 @@ import {
} from "@/lib/queries"; } from "@/lib/queries";
import type { ForwardZone, ForwardZoneInput } from "@/lib/types"; import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { useCrudForm } from "@/ui/useCrudForm";
const inputClass = import {
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900"; formCardClass,
const primaryButtonClass = inputClass,
"rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"; largeButtonClass,
const secondaryButtonClass = largePrimaryButtonClass,
"rounded border border-zinc-300 px-3 py-2 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"; rowButtonClass,
const rowButtonClass = tableWrapClass,
"rounded px-2 py-1 text-sm text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400"; } from "@/ui/classes";
type FormState = { mode: "create" } | { mode: "edit"; zone: ForwardZone };
function ZoneForm({ function ZoneForm({
initial, initial,
@@ -43,10 +41,7 @@ function ZoneForm({
} }
return ( return (
<form <form onSubmit={submit} className={formCardClass}>
onSubmit={submit}
className="mt-4 max-w-lg space-y-3 rounded border border-zinc-200 p-4 dark:border-zinc-800"
>
<h3 className="font-medium">{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}</h3> <h3 className="font-medium">{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}</h3>
<div> <div>
<label htmlFor={`${id}-zone`} className="block text-sm font-medium"> <label htmlFor={`${id}-zone`} className="block text-sm font-medium">
@@ -75,10 +70,10 @@ function ZoneForm({
/> />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button type="submit" disabled={busy} className={primaryButtonClass}> <button type="submit" disabled={busy} className={largePrimaryButtonClass}>
{busy ? "Saving…" : "Save"} {busy ? "Saving…" : "Save"}
</button> </button>
<button type="button" onClick={onCancel} className={secondaryButtonClass}> <button type="button" onClick={onCancel} className={largeButtonClass}>
Cancel Cancel
</button> </button>
</div> </div>
@@ -89,31 +84,15 @@ function ZoneForm({
export default function ZonesTab() { export default function ZonesTab() {
const zones = useSuspenseQuery(forwardZonesQuery()).data; const zones = useSuspenseQuery(forwardZonesQuery()).data;
const queryClient = useQueryClient(); const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm<
const create = useMutation(forwardZoneCreateMutation(queryClient)); ForwardZone,
const update = useMutation(forwardZoneUpdateMutation(queryClient)); ForwardZoneInput
const remove = useMutation(forwardZoneDeleteMutation(queryClient)); >({
const [form, setForm] = useState<FormState | null>(null); create: forwardZoneCreateMutation,
update: forwardZoneUpdateMutation,
function openForm(next: FormState) { remove: forwardZoneDeleteMutation,
create.reset(); confirmDelete: (zone) => `Delete forward zone "${zone.zone}"?`,
update.reset(); });
setForm(next);
}
function onSubmit(input: ForwardZoneInput) {
if (form === null) return;
if (form.mode === "create") {
create.mutate(input, { onSuccess: () => setForm(null) });
} else {
update.mutate({ id: form.zone.id, input }, { onSuccess: () => setForm(null) });
}
}
function onDelete(zone: ForwardZone) {
if (!window.confirm(`Delete forward zone "${zone.zone}"?`)) return;
remove.mutate(zone.id);
}
return ( return (
<div> <div>
@@ -121,20 +100,15 @@ export default function ZonesTab() {
<p className="text-sm text-zinc-500"> <p className="text-sm text-zinc-500">
Names under these zones go to their own resolver. Changes apply live. Names under these zones go to their own resolver. Changes apply live.
</p> </p>
<button type="button" onClick={() => openForm({ mode: "create" })} className={primaryButtonClass}> <button type="button" onClick={() => openForm({ mode: "create" })} className={largePrimaryButtonClass}>
Add zone Add zone
</button> </button>
</div> </div>
<InlineError error={remove.error} /> <InlineError error={remove.error} />
{form?.mode === "create" && ( {form?.mode === "create" && (
<ZoneForm <ZoneForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
busy={create.isPending}
error={create.error}
onSubmit={onSubmit}
onCancel={() => setForm(null)}
/>
)} )}
<div className="mt-4 overflow-x-auto"> <div className={tableWrapClass}>
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead> <thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800"> <tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
@@ -159,14 +133,14 @@ export default function ZonesTab() {
)} )}
{zones.map((zone) => ( {zones.map((zone) => (
<tr key={zone.id} className="border-b border-zinc-100 dark:border-zinc-900"> <tr key={zone.id} className="border-b border-zinc-100 dark:border-zinc-900">
{form?.mode === "edit" && form.zone.id === zone.id ? ( {form?.mode === "edit" && form.entity.id === zone.id ? (
<td colSpan={3}> <td colSpan={3}>
<ZoneForm <ZoneForm
initial={zone} initial={zone}
busy={update.isPending} busy={update.isPending}
error={update.error} error={update.error}
onSubmit={onSubmit} onSubmit={onSubmit}
onCancel={() => setForm(null)} onCancel={closeForm}
/> />
</td> </td>
) : ( ) : (
@@ -176,7 +150,7 @@ export default function ZonesTab() {
<td className="py-2 text-right whitespace-nowrap"> <td className="py-2 text-right whitespace-nowrap">
<button <button
type="button" type="button"
onClick={() => openForm({ mode: "edit", zone })} onClick={() => openForm({ mode: "edit", entity: zone })}
className={rowButtonClass} className={rowButtonClass}
> >
Edit Edit
+2 -8
View File
@@ -3,9 +3,7 @@ import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
import { groupsQuery, lookupQuery } from "@/lib/queries"; import { groupsQuery, lookupQuery } from "@/lib/queries";
import type { Group, LookupResult } from "@/lib/types"; import type { Group, LookupResult } from "@/lib/types";
import { inputClass, largePrimaryButtonClass } from "@/ui/classes";
const inputClass =
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
interface Submitted { interface Submitted {
domain: string; domain: string;
@@ -189,11 +187,7 @@ export default function LookupPage() {
))} ))}
</select> </select>
</div> </div>
<button <button type="submit" className={largePrimaryButtonClass} disabled={lookup.isFetching}>
type="submit"
className="rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
disabled={lookup.isFetching}
>
Look up Look up
</button> </button>
</form> </form>
+6 -6
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { pauseMutation, pauseQuery } from "@/lib/queries"; import { pauseMutation, pauseQuery } from "@/lib/queries";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { buttonClass, insetFocusRing } from "@/ui/classes";
const DURATIONS = [ const DURATIONS = [
{ label: "60 seconds", seconds: 60 }, { label: "60 seconds", seconds: 60 },
@@ -34,8 +35,7 @@ function useNowSeconds(active: boolean): number {
return now; return now;
} }
const BUTTON_CLASS = const triggerButtonClass = `${buttonClass} disabled:text-zinc-400 dark:disabled:text-zinc-600`;
"rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:text-zinc-400 dark:border-zinc-700 dark:disabled:text-zinc-600";
export default function PauseWidget() { export default function PauseWidget() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -52,7 +52,7 @@ export default function PauseWidget() {
if (data === undefined) { if (data === undefined) {
return ( return (
<button type="button" disabled className={BUTTON_CLASS}> <button type="button" disabled className={triggerButtonClass}>
Pause Pause
</button> </button>
); );
@@ -69,7 +69,7 @@ export default function PauseWidget() {
type="button" type="button"
onClick={() => mutation.mutate({ paused: false })} onClick={() => mutation.mutate({ paused: false })}
disabled={mutation.isPending} disabled={mutation.isPending}
className={BUTTON_CLASS} className={triggerButtonClass}
> >
Resume Resume
</button> </button>
@@ -92,7 +92,7 @@ export default function PauseWidget() {
aria-controls="pause-menu" aria-controls="pause-menu"
onClick={() => setMenuOpen((open) => !open)} onClick={() => setMenuOpen((open) => !open)}
disabled={mutation.isPending} disabled={mutation.isPending}
className={BUTTON_CLASS} className={triggerButtonClass}
> >
Pause Pause
</button> </button>
@@ -111,7 +111,7 @@ export default function PauseWidget() {
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds }, seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
); );
}} }}
className="px-3 py-1.5 text-left text-sm hover:bg-zinc-100 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600 dark:hover:bg-zinc-800" className={`px-3 py-1.5 text-left text-sm hover:bg-zinc-100 ${insetFocusRing} dark:hover:bg-zinc-800`}
> >
{label} {label}
</button> </button>
+16 -13
View File
@@ -5,11 +5,10 @@ import { formatMicros, formatTime } from "@/lib/format";
import { queriesInfiniteQuery } from "@/lib/queries"; import { queriesInfiniteQuery } from "@/lib/queries";
import type { QueriesFilter, QueryRow } from "@/lib/types"; import type { QueriesFilter, QueryRow } from "@/lib/types";
import { qtypeName } from "./qtype"; import { qtypeName } from "./qtype";
import { buttonClass, smallInputClass, tableWrapClass } from "@/ui/classes";
const inputClass = const filterInputClass = `mt-1 w-full ${smallInputClass}`;
"mt-1 w-full rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900"; const toolbarButtonClass = `${buttonClass} font-medium`;
const buttonClass =
"rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
function errorMessage(error: unknown): string { function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
@@ -134,7 +133,7 @@ export default function QueryLogPage() {
type="text" type="text"
value={domain} value={domain}
onChange={(event) => setDomain(event.target.value)} onChange={(event) => setDomain(event.target.value)}
className={inputClass} className={filterInputClass}
/> />
</label> </label>
<label className="block text-sm"> <label className="block text-sm">
@@ -143,12 +142,16 @@ export default function QueryLogPage() {
type="text" type="text"
value={client} value={client}
onChange={(event) => setClient(event.target.value)} onChange={(event) => setClient(event.target.value)}
className={inputClass} className={filterInputClass}
/> />
</label> </label>
<label className="block text-sm"> <label className="block text-sm">
Status Status
<select value={blocked} onChange={(event) => setBlocked(event.target.value)} className={inputClass}> <select
value={blocked}
onChange={(event) => setBlocked(event.target.value)}
className={filterInputClass}
>
<option value="any">All</option> <option value="any">All</option>
<option value="blocked">Blocked only</option> <option value="blocked">Blocked only</option>
<option value="allowed">Allowed only</option> <option value="allowed">Allowed only</option>
@@ -160,7 +163,7 @@ export default function QueryLogPage() {
type="datetime-local" type="datetime-local"
value={since} value={since}
onChange={(event) => setSince(event.target.value)} onChange={(event) => setSince(event.target.value)}
className={inputClass} className={filterInputClass}
/> />
</label> </label>
<label className="block text-sm"> <label className="block text-sm">
@@ -169,14 +172,14 @@ export default function QueryLogPage() {
type="datetime-local" type="datetime-local"
value={until} value={until}
onChange={(event) => setUntil(event.target.value)} onChange={(event) => setUntil(event.target.value)}
className={inputClass} className={filterInputClass}
/> />
</label> </label>
<div className="flex items-end gap-2 sm:col-span-2 lg:col-span-5"> <div className="flex items-end gap-2 sm:col-span-2 lg:col-span-5">
<button type="submit" className={buttonClass}> <button type="submit" className={toolbarButtonClass}>
Apply filters Apply filters
</button> </button>
<button type="button" onClick={clearFilters} className={buttonClass}> <button type="button" onClick={clearFilters} className={toolbarButtonClass}>
Clear Clear
</button> </button>
{base.isFetching && ( {base.isFetching && (
@@ -197,7 +200,7 @@ export default function QueryLogPage() {
</p> </p>
) : ( ) : (
<> <>
<div className="mt-4 overflow-x-auto rounded border border-zinc-200 dark:border-zinc-800"> <div className={`${tableWrapClass} rounded border border-zinc-200 dark:border-zinc-800`}>
<table className="w-full text-sm"> <table className="w-full text-sm">
<QueryTableHead /> <QueryTableHead />
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800"> <tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
@@ -219,7 +222,7 @@ export default function QueryLogPage() {
type="button" type="button"
onClick={loadMore} onClick={loadMore}
disabled={base.isFetchingNextPage || base.isPlaceholderData} disabled={base.isFetchingNextPage || base.isPlaceholderData}
className={buttonClass} className={toolbarButtonClass}
> >
{base.isFetchingNextPage ? "Loading…" : "Load more"} {base.isFetchingNextPage ? "Loading…" : "Load more"}
</button> </button>
+20 -28
View File
@@ -4,11 +4,7 @@ import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries"; import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries";
import type { Rule, RuleAction, RuleKind } from "@/lib/types"; import type { Rule, RuleAction, RuleKind } from "@/lib/types";
import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
const INPUT_CLASS =
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
export default function RulesPage() { export default function RulesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -44,16 +40,16 @@ export default function RulesPage() {
{rules.length === 0 ? ( {rules.length === 0 ? (
<p className="mt-4 text-zinc-500">No allow or block rules yet. Create one below.</p> <p className="mt-4 text-zinc-500">No allow or block rules yet. Create one below.</p>
) : ( ) : (
<div className="mt-4 overflow-x-auto"> <div className={tableWrapClass}>
<table className="w-full min-w-max border-collapse text-sm"> <table className="w-full min-w-max border-collapse text-sm">
<thead> <thead>
<tr> <tr>
<th className={TH_CLASS}>Pattern</th> <th className={thClass}>Pattern</th>
<th className={TH_CLASS}>Kind</th> <th className={thClass}>Kind</th>
<th className={TH_CLASS}>Action</th> <th className={thClass}>Action</th>
<th className={TH_CLASS}>Group</th> <th className={thClass}>Group</th>
<th className={TH_CLASS}>Created</th> <th className={thClass}>Created</th>
<th className={TH_CLASS}> <th className={thClass}>
<span className="sr-only">Actions</span> <span className="sr-only">Actions</span>
</th> </th>
</tr> </tr>
@@ -61,9 +57,9 @@ export default function RulesPage() {
<tbody> <tbody>
{rules.map((rule) => ( {rules.map((rule) => (
<tr key={rule.id}> <tr key={rule.id}>
<td className={`${TD_CLASS} font-medium`}>{rule.pattern}</td> <td className={`${tdClass} font-medium`}>{rule.pattern}</td>
<td className={TD_CLASS}>{rule.kind}</td> <td className={tdClass}>{rule.kind}</td>
<td className={TD_CLASS}> <td className={tdClass}>
<span <span
className={ className={
rule.action === "allow" rule.action === "allow"
@@ -74,14 +70,14 @@ export default function RulesPage() {
{rule.action} {rule.action}
</span> </span>
</td> </td>
<td className={TD_CLASS}>{rule.group}</td> <td className={tdClass}>{rule.group}</td>
<td className={TD_CLASS}>{formatTime(rule.created_at)}</td> <td className={tdClass}>{formatTime(rule.created_at)}</td>
<td className={TD_CLASS}> <td className={tdClass}>
<button <button
type="button" type="button"
onClick={() => deleteRule(rule)} onClick={() => deleteRule(rule)}
disabled={remove.isPending} disabled={remove.isPending}
className="text-sm font-medium text-red-600 disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-red-400" className={dangerLinkButtonClass}
> >
Delete Delete
</button> </button>
@@ -107,7 +103,7 @@ export default function RulesPage() {
value={pattern} value={pattern}
onChange={(event) => setPattern(event.target.value)} onChange={(event) => setPattern(event.target.value)}
placeholder="ads.example.com or *.example.com" placeholder="ads.example.com or *.example.com"
className={INPUT_CLASS} className={inputClass}
/> />
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
@@ -119,7 +115,7 @@ export default function RulesPage() {
id="rule-kind" id="rule-kind"
value={kind} value={kind}
onChange={(event) => setKind(event.target.value as RuleKind)} onChange={(event) => setKind(event.target.value as RuleKind)}
className={INPUT_CLASS} className={inputClass}
> >
<option value="exact">exact</option> <option value="exact">exact</option>
<option value="wildcard">wildcard</option> <option value="wildcard">wildcard</option>
@@ -133,7 +129,7 @@ export default function RulesPage() {
id="rule-action" id="rule-action"
value={action} value={action}
onChange={(event) => setAction(event.target.value as RuleAction)} onChange={(event) => setAction(event.target.value as RuleAction)}
className={INPUT_CLASS} className={inputClass}
> >
<option value="allow">allow</option> <option value="allow">allow</option>
<option value="block">block</option> <option value="block">block</option>
@@ -147,7 +143,7 @@ export default function RulesPage() {
id="rule-group" id="rule-group"
value={groupId} value={groupId}
onChange={(event) => setGroupId(Number(event.target.value))} onChange={(event) => setGroupId(Number(event.target.value))}
className={INPUT_CLASS} className={inputClass}
> >
{groups.map((group) => ( {groups.map((group) => (
<option key={group.id} value={group.id}> <option key={group.id} value={group.id}>
@@ -157,11 +153,7 @@ export default function RulesPage() {
</select> </select>
</div> </div>
</div> </div>
<button <button type="submit" disabled={create.isPending} className={primaryButtonClass}>
type="submit"
disabled={create.isPending}
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
{create.isPending ? "Creating…" : "Create rule"} {create.isPending ? "Creating…" : "Create rule"}
</button> </button>
<InlineError error={create.error} /> <InlineError error={create.error} />
+2 -1
View File
@@ -1,4 +1,5 @@
import { dismissRestartBanner, useRestartBanner } from "./restartBanner"; import { dismissRestartBanner, useRestartBanner } from "./restartBanner";
import { focusRing } from "@/ui/classes";
export default function RestartBanner() { export default function RestartBanner() {
const raised = useRestartBanner(); const raised = useRestartBanner();
@@ -12,7 +13,7 @@ export default function RestartBanner() {
<button <button
type="button" type="button"
onClick={dismissRestartBanner} onClick={dismissRestartBanner}
className="rounded border border-amber-400 px-2 py-1 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-amber-700" className={`rounded border border-amber-400 px-2 py-1 text-xs ${focusRing} dark:border-amber-700`}
> >
Dismiss Dismiss
</button> </button>
+9 -9
View File
@@ -5,6 +5,7 @@ import { settingsPutMutation, settingsQuery } from "@/lib/queries";
import { buildSettingsPatch } from "@/lib/settingsDiff"; import { buildSettingsPatch } from "@/lib/settingsDiff";
import type { Settings, SettingsPatch } from "@/lib/types"; import type { Settings, SettingsPatch } from "@/lib/types";
import { raiseRestartBanner } from "./restartBanner"; import { raiseRestartBanner } from "./restartBanner";
import { focusRing } from "@/ui/classes";
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */ /** True when the patch touches anything besides the write-only `web.password` (ruling 11). */
export function patchRequiresRestart(patch: SettingsPatch): boolean { export function patchRequiresRestart(patch: SettingsPatch): boolean {
@@ -120,8 +121,7 @@ const SECTIONS: readonly SectionDef[] = [
]; ];
const LABEL_CLASS = "text-sm text-zinc-700 dark:text-zinc-300"; const LABEL_CLASS = "text-sm text-zinc-700 dark:text-zinc-300";
const INPUT_CLASS = const fieldInputClass = `rounded border border-zinc-300 bg-white px-2 py-1 text-sm ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
"rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
function FieldRow({ function FieldRow({
section, section,
@@ -143,7 +143,7 @@ function FieldRow({
type="checkbox" type="checkbox"
checked={value as boolean} checked={value as boolean}
onChange={(e) => onChange(e.target.checked)} onChange={(e) => onChange(e.target.checked)}
className="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" className={focusRing}
/> />
<label htmlFor={id} className={LABEL_CLASS}> <label htmlFor={id} className={LABEL_CLASS}>
{def.key} {def.key}
@@ -161,7 +161,7 @@ function FieldRow({
id={id} id={id}
value={value as string} value={value as string}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className={INPUT_CLASS} className={fieldInputClass}
> >
{def.kind.map((option) => ( {def.kind.map((option) => (
<option key={option} value={option}> <option key={option} value={option}>
@@ -184,7 +184,7 @@ function FieldRow({
type="number" type="number"
value={Number.isNaN(numeric) ? "" : numeric} value={Number.isNaN(numeric) ? "" : numeric}
onChange={(e) => onChange(e.target.valueAsNumber)} onChange={(e) => onChange(e.target.valueAsNumber)}
className={INPUT_CLASS} className={fieldInputClass}
/> />
</div> </div>
); );
@@ -199,7 +199,7 @@ function FieldRow({
type="text" type="text"
value={value as string} value={value as string}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className={INPUT_CLASS} className={fieldInputClass}
/> />
</div> </div>
); );
@@ -280,7 +280,7 @@ export default function SettingsPage() {
autoComplete="new-password" autoComplete="new-password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className={INPUT_CLASS} className={fieldInputClass}
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -293,7 +293,7 @@ export default function SettingsPage() {
autoComplete="new-password" autoComplete="new-password"
value={confirm} value={confirm}
onChange={(e) => setConfirm(e.target.value)} onChange={(e) => setConfirm(e.target.value)}
className={INPUT_CLASS} className={fieldInputClass}
/> />
</div> </div>
{password !== "" && ( {password !== "" && (
@@ -316,7 +316,7 @@ export default function SettingsPage() {
<button <button
type="submit" type="submit"
disabled={saveDisabled} disabled={saveDisabled}
className="rounded bg-blue-600 px-4 py-1.5 text-sm font-medium text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:bg-zinc-300 disabled:text-zinc-500 dark:disabled:bg-zinc-800" className={`rounded bg-blue-600 px-4 py-1.5 text-sm font-medium text-white ${focusRing} disabled:bg-zinc-300 disabled:text-zinc-500 dark:disabled:bg-zinc-800`}
> >
{mutation.isPending ? "Saving…" : "Save"} {mutation.isPending ? "Saving…" : "Save"}
</button> </button>
+12 -17
View File
@@ -1,9 +1,7 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import type { Upstream, UpstreamInput } from "@/lib/types"; import type { Upstream, UpstreamInput } from "@/lib/types";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
const INPUT_CLASS =
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
const DEFAULT_PRIORITY = "100"; const DEFAULT_PRIORITY = "100";
@@ -57,7 +55,7 @@ export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel
value={url} value={url}
onChange={(event) => setUrl(event.target.value)} onChange={(event) => setUrl(event.target.value)}
placeholder="udp://1.1.1.1:53" placeholder="udp://1.1.1.1:53"
className={INPUT_CLASS} className={inputClass}
/> />
</div> </div>
<div> <div>
@@ -70,7 +68,7 @@ export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel
min={0} min={0}
value={priority} value={priority}
onChange={(event) => setPriority(event.target.value)} onChange={(event) => setPriority(event.target.value)}
className={INPUT_CLASS} className={inputClass}
/> />
</div> </div>
<div> <div>
@@ -83,30 +81,27 @@ export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel
value={tlsName} value={tlsName}
onChange={(event) => setTlsName(event.target.value)} onChange={(event) => setTlsName(event.target.value)}
placeholder="one.one.one.one" placeholder="one.one.one.one"
className={INPUT_CLASS} className={inputClass}
/> />
<p className="mt-1 text-xs text-zinc-500"> <p className="mt-1 text-xs text-zinc-500">
The SNI and certificate name for a <code>tls://</code> upstream. Leave empty for every other scheme. The SNI and certificate name for a <code>tls://</code> upstream. Leave empty for every other scheme.
</p> </p>
</div> </div>
<label className="flex items-center gap-2 text-sm font-medium"> <label className="flex items-center gap-2 text-sm font-medium">
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} /> <input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
className={focusRing}
/>
Enabled Enabled
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button type="submit" disabled={busy} className={primaryButtonClass}>
type="submit"
disabled={busy}
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
{initial === undefined ? "Add upstream" : "Save changes"} {initial === undefined ? "Add upstream" : "Save changes"}
</button> </button>
{onCancel !== undefined && ( {onCancel !== undefined && (
<button <button type="button" onClick={onCancel} className={`${buttonClass} font-medium`}>
type="button"
onClick={onCancel}
className="rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
>
Cancel Cancel
</button> </button>
)} )}
+15 -16
View File
@@ -5,9 +5,7 @@ import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation,
import type { Upstream, UpstreamInput } from "@/lib/types"; import type { Upstream, UpstreamInput } from "@/lib/types";
import { raiseRestartBanner } from "../settings/restartBanner"; import { raiseRestartBanner } from "../settings/restartBanner";
import UpstreamForm from "./UpstreamForm"; import UpstreamForm from "./UpstreamForm";
import { dangerLinkButtonClass, focusRing, linkButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
export default function UpstreamsPage() { export default function UpstreamsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -59,15 +57,15 @@ export default function UpstreamsPage() {
{upstreams.length === 0 ? ( {upstreams.length === 0 ? (
<p className="mt-4 text-zinc-500">No upstreams yet. Add one below.</p> <p className="mt-4 text-zinc-500">No upstreams yet. Add one below.</p>
) : ( ) : (
<div className="mt-4 overflow-x-auto"> <div className={tableWrapClass}>
<table className="w-full min-w-max border-collapse text-sm"> <table className="w-full min-w-max border-collapse text-sm">
<thead> <thead>
<tr> <tr>
<th className={TH_CLASS}>URL</th> <th className={thClass}>URL</th>
<th className={TH_CLASS}>Priority</th> <th className={thClass}>Priority</th>
<th className={TH_CLASS}>Enabled</th> <th className={thClass}>Enabled</th>
<th className={TH_CLASS}>TLS name</th> <th className={thClass}>TLS name</th>
<th className={TH_CLASS}> <th className={thClass}>
<span className="sr-only">Actions</span> <span className="sr-only">Actions</span>
</th> </th>
</tr> </tr>
@@ -75,28 +73,29 @@ export default function UpstreamsPage() {
<tbody> <tbody>
{upstreams.map((u) => ( {upstreams.map((u) => (
<tr key={u.id}> <tr key={u.id}>
<td className={TD_CLASS}> <td className={tdClass}>
<span className="block max-w-72 truncate font-medium" title={u.url}> <span className="block max-w-72 truncate font-medium" title={u.url}>
{u.url} {u.url}
</span> </span>
</td> </td>
<td className={`${TD_CLASS} tabular-nums`}>{u.priority}</td> <td className={`${tdClass} tabular-nums`}>{u.priority}</td>
<td className={TD_CLASS}> <td className={tdClass}>
<input <input
type="checkbox" type="checkbox"
aria-label={`${u.url} enabled`} aria-label={`${u.url} enabled`}
checked={u.enabled} checked={u.enabled}
disabled={toggle.isPending} disabled={toggle.isPending}
onChange={() => toggleEnabled(u)} onChange={() => toggleEnabled(u)}
className={focusRing}
/> />
</td> </td>
<td className={TD_CLASS}>{u.tls_name === "" ? "—" : u.tls_name}</td> <td className={tdClass}>{u.tls_name === "" ? "—" : u.tls_name}</td>
<td className={TD_CLASS}> <td className={tdClass}>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
type="button" type="button"
onClick={() => setEditing(u)} onClick={() => setEditing(u)}
className="text-sm font-medium text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400" className={linkButtonClass}
> >
Edit Edit
</button> </button>
@@ -104,7 +103,7 @@ export default function UpstreamsPage() {
type="button" type="button"
onClick={() => deleteUpstream(u)} onClick={() => deleteUpstream(u)}
disabled={remove.isPending} disabled={remove.isPending}
className="text-sm font-medium text-red-600 disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-red-400" className={dangerLinkButtonClass}
> >
Delete Delete
</button> </button>
+24
View File
@@ -0,0 +1,24 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { ApiError } from "@/lib/api";
import InlineError from "./InlineError";
test("no retry button without onRetry", () => {
render(<InlineError error={new ApiError(409, "already exists")} />);
expect(screen.getByRole("alert").textContent).toBe("already exists");
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
});
test("onRetry renders a focusable retry button that calls back", () => {
const onRetry = vi.fn();
render(<InlineError error={new ApiError(500, "internal")} onRetry={onRetry} />);
const button = screen.getByRole("button", { name: "Retry" });
expect(button.className).toContain("focus-visible:outline-2");
fireEvent.click(button);
expect(onRetry).toHaveBeenCalledTimes(1);
});
test("a null error renders nothing even with onRetry", () => {
const { container } = render(<InlineError error={null} onRetry={() => undefined} />);
expect(container.innerHTML).toBe("");
});
+14 -2
View File
@@ -1,8 +1,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
import { focusRing } from "@/ui/classes";
/** Inline mutation error per ruling 17: 400/409 messages verbatim, 429 with countdown. */ /**
export default function InlineError({ error }: { error: unknown }) { * Inline mutation error per ruling 17: 400/409 messages verbatim, 429 with
* countdown. Pass `onRetry` to append a retry button for a failed query.
*/
export default function InlineError({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
const retryAfter = error instanceof ApiError && error.status === 429 ? (error.retryAfter ?? null) : null; const retryAfter = error instanceof ApiError && error.status === 429 ? (error.retryAfter ?? null) : null;
const [remaining, setRemaining] = useState<number | null>(retryAfter); const [remaining, setRemaining] = useState<number | null>(retryAfter);
@@ -34,6 +38,14 @@ export default function InlineError({ error }: { error: unknown }) {
return ( return (
<p role="alert" className="mt-2 text-sm text-red-600 dark:text-red-400"> <p role="alert" className="mt-2 text-sm text-red-600 dark:text-red-400">
{message} {message}
{onRetry !== undefined && (
<>
{" "}
<button type="button" onClick={onRetry} className={`font-medium underline ${focusRing}`}>
Retry
</button>
</>
)}
</p> </p>
); );
} }
+2 -5
View File
@@ -27,6 +27,7 @@ import {
upstreamHealthQuery, upstreamHealthQuery,
upstreamsQuery, upstreamsQuery,
} from "@/lib/queries"; } from "@/lib/queries";
import { retryButtonClass } from "@/ui/classes";
export interface RouterContext { export interface RouterContext {
queryClient: QueryClient; queryClient: QueryClient;
@@ -64,11 +65,7 @@ function RouteError({ error }: ErrorComponentProps) {
> >
<h2 className="font-semibold text-red-800 dark:text-red-200">{title}</h2> <h2 className="font-semibold text-red-800 dark:text-red-200">{title}</h2>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">{detail}</p> <p className="mt-1 text-sm text-red-700 dark:text-red-300">{detail}</p>
<button <button type="button" onClick={() => void router.invalidate()} className={retryButtonClass}>
type="button"
onClick={() => void router.invalidate()}
className="mt-3 rounded border border-red-300 px-3 py-1.5 text-sm font-medium text-red-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-red-800 dark:text-red-200"
>
Retry Retry
</button> </button>
</div> </div>
+4 -3
View File
@@ -6,6 +6,7 @@ import InlineError from "@/lib/InlineError";
import { versionQuery } from "@/lib/queries"; import { versionQuery } from "@/lib/queries";
import PauseWidget from "../features/pause/PauseWidget"; import PauseWidget from "../features/pause/PauseWidget";
import RestartBanner from "../features/settings/RestartBanner"; import RestartBanner from "../features/settings/RestartBanner";
import { buttonClass, focusRing } from "@/ui/classes";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ to: "/", label: "Dashboard" }, { to: "/", label: "Dashboard" },
@@ -38,7 +39,7 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
className: className:
"text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-100", "text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-100",
}} }}
className="block rounded px-3 py-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" className={`block rounded px-3 py-1.5 ${focusRing}`}
> >
{item.label} {item.label}
</Link> </Link>
@@ -73,7 +74,7 @@ function LogoutButton() {
(logoutError: unknown) => setError(logoutError), (logoutError: unknown) => setError(logoutError),
); );
}} }}
className="rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700" className={buttonClass}
> >
Log out Log out
</button> </button>
@@ -97,7 +98,7 @@ export default function AppShell() {
<header className="flex items-center gap-3 border-b border-zinc-200 px-4 py-2 dark:border-zinc-800"> <header className="flex items-center gap-3 border-b border-zinc-200 px-4 py-2 dark:border-zinc-800">
<button <button
type="button" type="button"
className="rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 md:hidden dark:border-zinc-700" className={`${buttonClass} md:hidden`}
aria-expanded={drawerOpen} aria-expanded={drawerOpen}
aria-controls="mobile-nav" aria-controls="mobile-nav"
onClick={() => setDrawerOpen((open) => !open)} onClick={() => setDrawerOpen((open) => !open)}
+32
View File
@@ -0,0 +1,32 @@
/**
* The shared Tailwind class vocabulary (milestone 18, ruling 10).
*
* Every interactive element must carry `focusRing`; that is the milestone-9
* accessibility floor. Compose these constants for one-off variants
* (`` `${buttonClass} md:hidden` ``) instead of re-spelling the literal.
*/
export const focusRing = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
/** The ring drawn inside the element, for controls flush against a panel edge. */
export const insetFocusRing = "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600";
export const inputClass = `mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
export const smallInputClass = `rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
export const buttonClass = `rounded border border-zinc-300 px-3 py-1.5 text-sm ${focusRing} dark:border-zinc-700`;
export const smallButtonClass = `rounded border border-zinc-300 px-2 py-1 text-sm ${focusRing} dark:border-zinc-700`;
export const largeButtonClass = `rounded border border-zinc-300 px-3 py-2 font-medium ${focusRing} dark:border-zinc-700`;
export const primaryButtonClass = `rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 ${focusRing}`;
export const largePrimaryButtonClass = `rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 ${focusRing}`;
export const rowButtonClass = `rounded px-2 py-1 text-sm text-blue-600 ${focusRing} dark:text-blue-400`;
export const linkButtonClass = `text-sm font-medium text-blue-600 ${focusRing} dark:text-blue-400`;
export const dangerLinkButtonClass = `text-sm font-medium text-red-600 disabled:opacity-50 ${focusRing} dark:text-red-400`;
export const retryButtonClass = `mt-3 rounded border border-red-300 px-3 py-1.5 text-sm font-medium text-red-800 ${focusRing} dark:border-red-800 dark:text-red-200`;
export const thClass = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
export const tdClass = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
export const tableWrapClass = "mt-4 overflow-x-auto";
export const formCardClass = "mt-4 max-w-lg space-y-3 rounded border border-zinc-200 p-4 dark:border-zinc-800";
+118
View File
@@ -0,0 +1,118 @@
import type { ReactNode } from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider, type QueryClient as QC } from "@tanstack/react-query";
import { useCrudForm } from "./useCrudForm";
interface Row {
id: number;
name: string;
}
function setup() {
const created: string[] = [];
const updated: { id: number; input: string }[] = [];
const removed: number[] = [];
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const hook = renderHook(
() =>
useCrudForm<Row, string>({
create: (_qc: QC) => ({
mutationFn: async (input: string) => {
created.push(input);
},
}),
update: (_qc: QC) => ({
mutationFn: async (vars: { id: number; input: string }) => {
updated.push(vars);
},
}),
remove: (_qc: QC) => ({
mutationFn: async (id: number) => {
removed.push(id);
},
}),
confirmDelete: (row) => `Delete "${row.name}"?`,
}),
{ wrapper },
);
return { hook, created, updated, removed };
}
afterEach(() => vi.unstubAllGlobals());
test("the create form submits through the create mutation and closes", async () => {
const { hook, created, updated } = setup();
act(() => hook.result.current.openForm({ mode: "create" }));
expect(hook.result.current.form).toEqual({ mode: "create" });
act(() => hook.result.current.onSubmit("alpha"));
await waitFor(() => expect(hook.result.current.form).toBeNull());
expect(created).toEqual(["alpha"]);
expect(updated).toEqual([]);
});
test("the edit form submits the row id through the update mutation", async () => {
const { hook, created, updated } = setup();
act(() => hook.result.current.openForm({ mode: "edit", entity: { id: 7, name: "beta" } }));
act(() => hook.result.current.onSubmit("beta2"));
await waitFor(() => expect(hook.result.current.form).toBeNull());
expect(updated).toEqual([{ id: 7, input: "beta2" }]);
expect(created).toEqual([]);
});
test("submitting with no form open does nothing", () => {
const { hook, created, updated } = setup();
act(() => hook.result.current.onSubmit("ignored"));
expect(created).toEqual([]);
expect(updated).toEqual([]);
});
test("openForm clears a stale create error so the reopened form starts clean", async () => {
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const hook = renderHook(
() =>
useCrudForm<Row, string>({
create: (_qc: QC) => ({ mutationFn: () => Promise.reject(new Error("boom")) }),
update: (_qc: QC) => ({ mutationFn: async () => undefined }),
remove: (_qc: QC) => ({ mutationFn: async () => undefined }),
confirmDelete: () => "?",
}),
{ wrapper },
);
act(() => hook.result.current.openForm({ mode: "create" }));
act(() => hook.result.current.onSubmit("alpha"));
await waitFor(() => expect(hook.result.current.create.error).not.toBeNull());
expect(hook.result.current.form).toEqual({ mode: "create" });
act(() => hook.result.current.openForm({ mode: "create" }));
await waitFor(() => expect(hook.result.current.create.error).toBeNull());
});
test("delete asks for confirmation and only removes when confirmed", async () => {
const { hook, removed } = setup();
const confirm = vi.fn(() => false);
vi.stubGlobal("confirm", confirm);
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
expect(confirm).toHaveBeenCalledWith('Delete "gamma"?');
expect(removed).toEqual([]);
confirm.mockReturnValue(true);
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
await waitFor(() => expect(removed).toEqual([3]));
});
test("closeForm closes whichever form is open", () => {
const { hook } = setup();
act(() => hook.result.current.openForm({ mode: "edit", entity: { id: 1, name: "delta" } }));
act(() => hook.result.current.closeForm());
expect(hook.result.current.form).toBeNull();
});
+54
View File
@@ -0,0 +1,54 @@
import { useState } from "react";
import { useMutation, useQueryClient, type QueryClient, type UseMutationOptions } from "@tanstack/react-query";
/** Which form is open: none, the create form, or the edit form for one row. */
export type CrudFormState<Entity> = { mode: "create" } | { mode: "edit"; entity: Entity };
type MutationFactory<Variables> = (queryClient: QueryClient) => UseMutationOptions<unknown, Error, Variables>;
export interface CrudFormSpec<Entity extends { id: number }, Input> {
create: MutationFactory<Input>;
update: MutationFactory<{ id: number; input: Input }>;
remove: MutationFactory<number>;
/** The `window.confirm` text shown before a delete. */
confirmDelete: (entity: Entity) => string;
}
/**
* The create/edit/delete plumbing shared by the local DNS tabs (ruling 10):
* three mutations, the open-form state, and the three handlers. The field JSX
* and the table stay in the caller.
*/
export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudFormSpec<Entity, Input>) {
const queryClient = useQueryClient();
const create = useMutation(spec.create(queryClient));
const update = useMutation(spec.update(queryClient));
const remove = useMutation(spec.remove(queryClient));
const [form, setForm] = useState<CrudFormState<Entity> | null>(null);
function openForm(next: CrudFormState<Entity>) {
create.reset();
update.reset();
setForm(next);
}
function closeForm() {
setForm(null);
}
function onSubmit(input: Input) {
if (form === null) return;
if (form.mode === "create") {
create.mutate(input, { onSuccess: () => setForm(null) });
} else {
update.mutate({ id: form.entity.id, input }, { onSuccess: () => setForm(null) });
}
}
function onDelete(entity: Entity) {
if (!window.confirm(spec.confirmDelete(entity))) return;
remove.mutate(entity.id);
}
return { create, update, remove, form, openForm, closeForm, onSubmit, onDelete };
}