milestone 18: collapse duplicated infrastructure into shared listener core, crud list helper, resource shells, transport race, name and line helpers, ui modules
This commit is contained in:
+14
-12
@@ -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.**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Vendored
+4
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -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
@@ -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)));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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" {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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").?;
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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..]);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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..]);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
});
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user