milestone 7: serving pipeline, client tracking, pause and lifecycle
This commit is contained in:
@@ -0,0 +1,700 @@
|
|||||||
|
# Milestone 7: serving pipeline composition (PLAN Phase 7)
|
||||||
|
|
||||||
|
Goal: `nxdns run` serves DNS end to end — rate limit → parse → group → local records →
|
||||||
|
forward zones → filtering → safe-search → cache → upstream → CNAME uncloaking → cache store →
|
||||||
|
async query log — with client auto-materialization, pause/resume, and a composition root that
|
||||||
|
starts every background loop and shuts down cleanly on SIGINT/SIGTERM.
|
||||||
|
|
||||||
|
Ground truth: PLAN.md §4 (pipeline diagram, lines 151-173), §6 (DNS behavior), §7.2
|
||||||
|
(auto-materialization), §8 (cache key), §10 (rate limit), §11.4/§11.6 (logging, disk), Phase 7
|
||||||
|
text at PLAN.md:606-608. Verified Zig facts: specs/research/zig-0.16-api-notes.md plus the
|
||||||
|
scratchpad note m7-explore-zig.md (signals, Io.Group, cancelable net ops) — the load-bearing
|
||||||
|
facts are restated inline below where a session needs them.
|
||||||
|
|
||||||
|
## Rulings (engineering calls resolving PLAN ambiguities — binding)
|
||||||
|
|
||||||
|
1. **Scope**: Phase 7 owns `nxdns run` end to end: `src/app.zig` (composition root),
|
||||||
|
`src/server/shutdown.zig` (signal → event), the `cli.runRun` body, and both listener edits.
|
||||||
|
2. **Signals**: no signalfd/epoll/timerfd. `std.posix.sigaction` handler for INT and TERM that
|
||||||
|
calls `std.Io.Event.set` on a file-scope event (`set` is async-signal-safe on the Threaded
|
||||||
|
Linux backend: raw futex wake, no locks — Threaded.zig:1105,17235).
|
||||||
|
3. **Locking**: `DnsCache` and `RateLimiter` each get one `std.Io.Mutex` owned by the handler.
|
||||||
|
Household scale makes contention irrelevant; sharding is over-engineering.
|
||||||
|
4. **Null snapshot** (`Manager.acquire` returns null before the first successful reload): fail
|
||||||
|
open — skip group/filter/safe-search, use the default group semantics, count
|
||||||
|
`unfiltered_queries`. DNS availability beats filtering for a household. The app attempts one
|
||||||
|
synchronous `reload` before serving; a failure warns and serving starts anyway.
|
||||||
|
5. **Cache scope**: one global cache, no group in the key (PLAN §8). Safe because blocked
|
||||||
|
responses are never cached. Safe-search responses are not cached either (13 domains, rare;
|
||||||
|
caching them under the rewritten name would force answer re-encoding on hit). Paused-mode
|
||||||
|
upstream answers cache normally (they are ordinary upstream responses). Forward-zone
|
||||||
|
responses cache normally (PLAN §6.5) under the same global key.
|
||||||
|
6. **Local vs forward zone**: local records win (checked first; PLAN §6.4 puts them before
|
||||||
|
everything but rate limit/parse).
|
||||||
|
7. **Forward zones bypass** filtering, safe-search, and uncloaking (PLAN §6.5). They are logged
|
||||||
|
with `upstream` = the resolver's canonical text.
|
||||||
|
8. **Rate limit position**: after the 12-byte header read (the REFUSED reply needs the ID),
|
||||||
|
before full parse. Refused queries are counted (`Stats.refused`), never query-logged.
|
||||||
|
Localhost is not exempt (PLAN §10's localhost relaxation is the Phase 8 API limiter only).
|
||||||
|
9. **Non-IN qclass**: skip filtering/cache/safe-search/uncloak, go straight upstream, log
|
||||||
|
normally. Rare and harmless; special-casing CHAOS is scope creep.
|
||||||
|
10. **Safe-search mechanics** (per safesearch.zig:56 doc): the outgoing query's QNAME becomes
|
||||||
|
the target; the reply to the client keeps the original question, starts with a synthesized
|
||||||
|
CNAME original→target, then copies only the A/AAAA answer records from the upstream
|
||||||
|
response verbatim (their RDATA is raw bytes, so no compression-pointer re-encoding; other
|
||||||
|
rtypes are skipped). Applies only on the upstream path — never to local records or forward
|
||||||
|
zones.
|
||||||
|
11. **CNAME uncloaking** (PLAN §6.3): walk the answer-section CNAME chain of the upstream
|
||||||
|
response — no re-resolution. Depth 8 = at most 8 CNAME links followed. Each target is
|
||||||
|
normalized and evaluated for the same group; a blocked target synthesizes a blocked
|
||||||
|
response for the ORIGINAL qname/qtype. `block_reason` = `"cname:" ++ @tagName(reason)`
|
||||||
|
(fits the 32-byte Entry field). Uncloaked-blocked responses are not cached. Skipped when
|
||||||
|
paused, for forward zones, and for non-IN.
|
||||||
|
12. **Local CNAME records** are returned as-is (writeAnswers emits the CNAME and stops,
|
||||||
|
milestone-5 As-built). The client re-queries the target, which then flows through the full
|
||||||
|
pipeline — that is how §6.4's "targets resolve through the normal pipeline" is satisfied.
|
||||||
|
13. **ECS** (PLAN §6.1): `strip` (default) removes a client-sent ECS option from the outgoing
|
||||||
|
query by rebuilding it (header + question + OPT sans option 8); other options are
|
||||||
|
preserved. `forward` sends the query unchanged and puts the raw ECS option payload
|
||||||
|
(≤ `dns_cache.max_ecs_len` = 40 bytes) into the cache key. If a rewritten query cannot fit
|
||||||
|
the 512-byte build buffer (freak OPT payloads), the original is forwarded unchanged,
|
||||||
|
`Stats.ecs_strip_failed` counts it, and the query is excluded from the cache in both
|
||||||
|
directions (the subnet reached the resolver; the strip-mode key says nothing about it).
|
||||||
|
14. **Client auto-materialization** (PLAN §7.2): never a hot-path DB write. The handler records
|
||||||
|
the client in a mutex-guarded fixed-capacity pending table; a background loop flushes every
|
||||||
|
60 s with its OWN config.db connection: `INSERT ... hand_edited=0 ON CONFLICT(ip) DO UPDATE
|
||||||
|
last_seen`. At most one write per client per flush. Table full → drop silently (counted).
|
||||||
|
15. **Snapshot staleness**: a materialized client reaches the matcher snapshot only on the next
|
||||||
|
manager reload. Correct behavior does not depend on it: `groupForClient` already falls back
|
||||||
|
to prefix/default, and materialized rows exist for UI visibility (Phase 8 reads the DB
|
||||||
|
directly). No reload is triggered per client.
|
||||||
|
16. **Client pruning** (PLAN §7.2 + milestone-6 ruling 1): the same background loop prunes
|
||||||
|
daily: `DELETE FROM clients WHERE hand_edited=0 AND last_seen < now - retention_days`
|
||||||
|
(`logging.retention_days`, same knob as the query log — documented). `retention.zig` stays
|
||||||
|
frozen and querylog-only. `last_seen` is the only signal (§3.6 forbids cross-DB joins).
|
||||||
|
17. **Disk gate**: the client flush/prune loop skips its pass when `writesAllowed()` is false;
|
||||||
|
the logger holds its batches. As built: milestone-6.md:410 assigned the manager's
|
||||||
|
consumption to Phase 7 and no seam existed, so S6 added `Manager.monitor:
|
||||||
|
?*disk_monitor.Monitor` (set by the composition root after `init`, before `runScheduler`)
|
||||||
|
plus a `refreshes_gated` counter read through `refreshesGated()`. Only scheduled passes are
|
||||||
|
gated: `runScheduler`'s periodic pass and the source-refresh half of the startup pass.
|
||||||
|
`reload` and `refreshAll` stay ungated (operator actions). The startup pass's
|
||||||
|
`reloadLocked` runs ahead of the gate: publishing an already-compiled snapshot is a read,
|
||||||
|
and a full disk must not cost the household its filtering as well as its downloads. A
|
||||||
|
gated pass logs one warn line and counts.
|
||||||
|
18. **Pause/resume** (PLAN §13.1 mechanism): suspends FILTERING only (evaluate + safe-search +
|
||||||
|
uncloak are skipped); everything else — local records, forward zones, cache, upstream,
|
||||||
|
logging — runs unchanged. Global, not per-group. In-memory only; a restart resumes
|
||||||
|
filtering (safe default). State: one `std.atomic.Value(i64)` — 0 = active, -1 = paused
|
||||||
|
indefinitely, else = paused until that unix second (auto-resume by compare, no timer task).
|
||||||
|
Phase 8's endpoints will call `pauseFor`/`unpause` on the shared pointer.
|
||||||
|
19. **Paused queries** log `blocked=false, block_reason=""`; `Stats.paused_queries` counts
|
||||||
|
them. Evaluation is skipped entirely (not evaluated-then-ignored).
|
||||||
|
20. **Log field semantics**: `response_time_us` = wall time from `handle` entry to reply ready.
|
||||||
|
`cache_hit`: true/false on the upstream path, null for local/forward/refused-never-logged;
|
||||||
|
`upstream`: "" on cache hit, "local" for local records, resolver text for forward zones,
|
||||||
|
the pool endpoint's canonical text for upstream answers (pool already tracks which entry
|
||||||
|
answered — if unavailable without new plumbing, "" and a spec note; do not rebuild the
|
||||||
|
pool for it: `upstream` may be "pool" as the compromise, S2 decides and records).
|
||||||
|
`block_reason` = `@tagName(matcher.Reason)` truncated to 32, `"cname:"`-prefixed for
|
||||||
|
uncloaking. Malformed/dropped queries are never query-logged (handler stats only).
|
||||||
|
21. **Connections**: config.db — one opened by the app (migrate/bootstrap/readConfig, then
|
||||||
|
handed to the manager for reloads), plus one dedicated to the client loop. querylog.db —
|
||||||
|
one for the log writer, one for retention (retention.zig:79 contract). Four total.
|
||||||
|
22. **Shutdown order**: shutdown event fires → `logger.shutdown(io)` (closes the queue; the
|
||||||
|
writer drains and its task returns; a writer stuck in the disk gate holds its final batch —
|
||||||
|
known, documented) → `group.cancel(io)` (requests cancel and joins every task) → deinit
|
||||||
|
servers/pool/manager/state → close DBs → return. Canceled UDP recv / TCP accept return
|
||||||
|
`error.Canceled` on the Threaded backend (Threaded.zig:12439,12877).
|
||||||
|
23. **No runtime config re-read** in Phase 7. The blocklist scheduler reloads snapshots;
|
||||||
|
everything else is restart-required until Phase 8 mutates the DB and calls reload.
|
||||||
|
24. **No new config fields, no schema change.** Pause is in-memory; pruning reuses
|
||||||
|
`retention_days`; ECS uses the existing `edns.ecs_mode`.
|
||||||
|
25. **Performance** (PLAN §18): no formal benchmark in this milestone; the integration test
|
||||||
|
asserts correctness only. The targets stay in PLAN for a later measurement pass.
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
Dependency graph: S1 + S4 parallel (wave 1) → S2 (needs S1's edns API) → S3 (needs S2's
|
||||||
|
signature in code) → S6 (needs S2, S3, S4) → S7 (needs S6). The orchestrator wires
|
||||||
|
`src/tests.zig` after each wave.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session S1: ECS helpers in dns/edns.zig
|
||||||
|
|
||||||
|
Owns: `src/dns/edns.zig` (extend in place; existing API is frozen — additive only).
|
||||||
|
|
||||||
|
### S1.1 API
|
||||||
|
|
||||||
|
```zig
|
||||||
|
/// The raw payload of the ECS option (code 8) in `opt`, or null when absent.
|
||||||
|
/// `pkt` is the packet `opt` was parsed from. error.BadOption on malformed option lengths.
|
||||||
|
pub fn ecsPayload(pkt: packet.Packet, opt: OptRecord) error{BadOption}!?[]const u8
|
||||||
|
|
||||||
|
/// Rebuilds `query` without its ECS option into `out`: header + question copied verbatim,
|
||||||
|
/// then the OPT re-encoded with every option except code 8. Returns `.unchanged` when the
|
||||||
|
/// query has no OPT or no ECS option (nothing written), else `.rewritten` with the slice
|
||||||
|
/// into `out`. `pkt`/`opt` are the caller's already-parsed views of `query`.
|
||||||
|
/// error.Overflow when `out` is too small.
|
||||||
|
pub const StripResult = union(enum) { unchanged, rewritten: []u8 };
|
||||||
|
pub fn stripEcs(query: []const u8, pkt: packet.Packet, opt: OptRecord, out: []u8)
|
||||||
|
error{ BadOption, Overflow }!StripResult
|
||||||
|
```
|
||||||
|
|
||||||
|
Constraints: pure, allocation-free, no Io. The rebuilt query keeps the original ID, flags and
|
||||||
|
question bytes verbatim (copy `query[0..opt_record_offset_of_question_end]` — concretely: copy
|
||||||
|
everything up to the OPT record's start; the OPT is the sole additional record in a query per
|
||||||
|
the existing handler validation, and `packet.parse` yields its offset). ARCOUNT stays 1 when
|
||||||
|
the OPT is re-emitted (it always is — only the option list shrinks). Use the existing
|
||||||
|
`encodeOpt` for the re-emission. The RDATA walk follows the same bounds checks `findOption`
|
||||||
|
uses.
|
||||||
|
|
||||||
|
### S1.2 Acceptance
|
||||||
|
|
||||||
|
- [ ] `zig fmt --check` + `zig ast-check` clean.
|
||||||
|
- [ ] Tests in-file: ECS present → payload returned, stripped query parses cleanly and
|
||||||
|
re-parse finds no option 8, other options survive byte-exact, DO bit and payload size
|
||||||
|
survive; no OPT → `.unchanged`; OPT without ECS → `.unchanged`; malformed option length
|
||||||
|
→ `error.BadOption`; `out` one byte short → `error.Overflow`.
|
||||||
|
- [ ] Temp-root run of the file's tests passes.
|
||||||
|
|
||||||
|
### S1 As built
|
||||||
|
|
||||||
|
Two spec claims were wrong and the implementation corrects them. (1) The handler does NOT
|
||||||
|
guarantee the OPT is the sole additional record (handler.zig:105 rejects only misplaced OPTs;
|
||||||
|
packet.parse rejects only a second OPT) — `stripEcs` therefore copies the bytes after the OPT
|
||||||
|
verbatim and all four section counts survive. Caveat, documented on the function: a compression
|
||||||
|
pointer in a trailing record that targets a byte inside the OPT's option list shifts after the
|
||||||
|
rewrite — such a pointer is already malformed (RFC 1035 §4.1.4) and the consequence is an
|
||||||
|
upstream FORMERR. (2) `packet.parse` does not yield the OPT's start offset; a private
|
||||||
|
`optRecordStart` re-walks the sections and matches the record on its full RDATA span, so a
|
||||||
|
hand-built `OptRecord` cannot aim it at a different record (deriving the start backwards is
|
||||||
|
forgeable: a `0x00xx` transaction ID makes a pointer's low byte decode as a root name).
|
||||||
|
`encodeOpt` takes one contiguous options slice, so `stripEcs` emits it with an empty list,
|
||||||
|
streams the survivors, and patches RDLENGTH at `opt_rdlength_offset` (9); a guard test pins
|
||||||
|
that offset against `encodeOpt`'s real output. All options with code 8 are removed (a malformed
|
||||||
|
query can carry several). Walk failures map to `error.BadOption` (frozen error set; reachable
|
||||||
|
only for hand-assembled Packets, documented). After review: `stripEcs` asserts both that
|
||||||
|
`query` aliases `pkt.bytes` and that `out` is pointer-range disjoint from `query` in both
|
||||||
|
directions; the caller owns keeping the rebuild buffer separate from the received query
|
||||||
|
buffer (S2's `scratch.build` satisfies this). The import is aliased `packet_mod` (seven existing declarations
|
||||||
|
named `packet`). `ecsPayload` was built here, then removed in the review round when the
|
||||||
|
handler's `subnetForKey` single-walk replaced its only caller (see S2 As-built); the
|
||||||
|
forward-mode clamp policy lives there — a payload longer than `dns_cache.max_ecs_len` (40)
|
||||||
|
makes the query uncacheable (truncating the key would merge distinct subnets). 7 S1 tests
|
||||||
|
remain, 25 in file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session S2: pipeline in server/handler.zig (+ pause.zig)
|
||||||
|
|
||||||
|
Owns: `src/server/handler.zig` (rewrite around the existing skeleton), `src/server/pause.zig`
|
||||||
|
(new). The biggest session. Everything below is frozen interface — S3 and S6 build against it.
|
||||||
|
|
||||||
|
### S2.1 pause.zig
|
||||||
|
|
||||||
|
```zig
|
||||||
|
pub const Pause = struct {
|
||||||
|
until: std.atomic.Value(i64) = .init(0),
|
||||||
|
pub fn isPaused(self: *const Pause, now_s: i64) bool // 0→false, -1→true, else now_s < until
|
||||||
|
pub fn pauseFor(self: *Pause, now_s: i64, duration_s: ?u32) void // null → -1 (indefinite)
|
||||||
|
pub fn unpause(self: *Pause) void // store 0
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-resume is the compare in `isPaused` — no timer. `.monotonic` ordering is sufficient (a
|
||||||
|
lone i64 flag, no dependent data). Tests: the three states, expiry boundary (`now == until`
|
||||||
|
is resumed), pauseFor overwrites a previous pause.
|
||||||
|
|
||||||
|
### S2.2 Handler struct (frozen for S3/S6)
|
||||||
|
|
||||||
|
```zig
|
||||||
|
pub const Handler = struct {
|
||||||
|
upstream: transport.Client,
|
||||||
|
blocking: response.Options,
|
||||||
|
ecs_mode: model.EcsMode = .strip,
|
||||||
|
forward_read_timeout: std.Io.Clock.Duration,
|
||||||
|
manager: ?*manager.Manager = null,
|
||||||
|
records: *const records.Records,
|
||||||
|
zones: *const forward_zones.Zones,
|
||||||
|
cache: ?*dns_cache.DnsCache = null,
|
||||||
|
cache_mutex: std.Io.Mutex = .init,
|
||||||
|
limiter: ?*rate_limiter.RateLimiter = null,
|
||||||
|
limiter_mutex: std.Io.Mutex = .init,
|
||||||
|
logger: ?*logger_mod.Logger = null,
|
||||||
|
pause: ?*pause.Pause = null,
|
||||||
|
tracker: ?*clients.Tracker = null,
|
||||||
|
stats: Stats = .{},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Every `?*` defaults to null so existing tests and the check command construct a bare handler
|
||||||
|
exactly as today (upstream + blocking + records/zones + timeout are the only required fields;
|
||||||
|
`records`/`zones` may point at the shared `Records.empty`/`Zones.empty` constants — if
|
||||||
|
`Zones.empty` does not exist yet, S2 adds it mirroring `Records.empty`).
|
||||||
|
|
||||||
|
```zig
|
||||||
|
pub const Scratch = struct {
|
||||||
|
normalize: [types.max_name_len]u8,
|
||||||
|
key: [dns_cache.max_key_len]u8,
|
||||||
|
build: [512]u8, // rewritten outgoing query (ECS strip / safe-search)
|
||||||
|
frame: [forward_client.min_frame_buf]u8, // forward-zone client frame buffer
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn handle(self: *Handler, io: std.Io, which: Transport, from: address.NetAddress,
|
||||||
|
query: []const u8, response_buf: []u8, scratch: *Scratch) Outcome
|
||||||
|
```
|
||||||
|
|
||||||
|
`Outcome` unchanged. Still no error union — every failure is a synthesized rcode or a counted
|
||||||
|
drop. New Stats counters (all `std.atomic.Value(u64)`): `refused`, `blocked`,
|
||||||
|
`uncloak_blocked`, `local_answers`, `forward_zone_answers`, `cache_hits`, `paused_queries`,
|
||||||
|
`unfiltered_queries`, `safesearch_rewrites`, `ecs_strip_failed`, `tracker_full`.
|
||||||
|
|
||||||
|
### S2.3 Pipeline order inside handle (binding; rulings 4-13, 18-20 apply)
|
||||||
|
|
||||||
|
1. Read the 12-byte header (existing code). QR/opcode/qdcount checks as today.
|
||||||
|
2. Rate limit: `limiter_mutex.lock(io)`, `check(now, from.key())`, unlock. Refused →
|
||||||
|
synthesize REFUSED (reuse `synthesize`), count, return. `now` from
|
||||||
|
`std.Io.Clock.awake.now(io)` (limiter windows are awake-anchored, milestone-6 As-built).
|
||||||
|
3. Full parse + OPT validation (existing code). Record `start_us` once from
|
||||||
|
`std.Io.Clock.real.now(io)` for both `response_time_us` and cache `now_s`.
|
||||||
|
4. `tracker.track(from)` (nonblocking; full table counts `tracker_full`).
|
||||||
|
5. Snapshot: `if (self.manager) |m| m.acquire(io) else null`; released once on every exit path
|
||||||
|
(single defer). Null snapshot → `unfiltered_queries`, group features off (ruling 4).
|
||||||
|
6. Non-IN qclass → step 12 (upstream direct), no cache (ruling 9).
|
||||||
|
7. Normalize qname into `scratch.normalize`.
|
||||||
|
8. Local records: `hasName` → `lookup`; answer via `ResponseBuilder` + `writeAnswers` +
|
||||||
|
`addOptEcho`, authoritative. A name that exists with no records of the qtype → NODATA,
|
||||||
|
authoritative. Log (`upstream="local"`, `cache_hit=null`) and return.
|
||||||
|
9. Forward zones: `zones.match` → construct `ForwardClient` on the stack
|
||||||
|
(`init(zone.resolver, &scratch.frame, self.forward_read_timeout)`), cache get → miss →
|
||||||
|
`exchange` into `response_buf` → cache put (classify), log (`upstream=resolver text`),
|
||||||
|
return. Filtering/safe-search/uncloak bypassed (ruling 7). Exchange failure → SERVFAIL.
|
||||||
|
10. Pause check (`isPaused(now_s)`) → skip 11 and the uncloak in 14; count `paused_queries`.
|
||||||
|
11. Filtering: `snapshot.evaluate(group, domain)`; blocked → `response.writeBlocked` into
|
||||||
|
`response_buf`, log (`blocked=true`, reason), return. Then safe-search:
|
||||||
|
`snapshot.safeSearch(group)` and `safesearch.rewrite(domain)` → remember the target;
|
||||||
|
outgoing QNAME = target (query rebuilt in `scratch.build`; count `safesearch_rewrites`).
|
||||||
|
12. ECS (ruling 13): `.strip` → `edns.stripEcs` into `scratch.build` (compose with the
|
||||||
|
safe-search rebuild: build once with both the final QNAME and the filtered OPT — one
|
||||||
|
rebuild, not two); `.forward` → `edns.ecsPayload` for the key. Overflow →
|
||||||
|
`ecs_strip_failed`, forward original.
|
||||||
|
13. Cache get (skip for safe-search, ruling 5): key = `buildKey(outgoing qname, qtype, qclass,
|
||||||
|
do_bit, ecs_payload_or_null)` under `cache_mutex`; hit → copy is already in
|
||||||
|
`response_buf`-sized `out` (pass `response_buf`), `packet.setId`, UDP truncation check as
|
||||||
|
today, log (`cache_hit=true`, `upstream=""`), return.
|
||||||
|
14. Upstream: `self.upstream.exchange(io, outgoing, response_buf)` (existing error mapping to
|
||||||
|
SERVFAIL/drop stays). Then uncloak (ruling 11) unless paused/non-IN: iterate
|
||||||
|
`packet.answers`, follow CNAMEs from the outgoing qname via `record.rdataCname`, depth ≤ 8,
|
||||||
|
normalize each target, `evaluate`; blocked → `writeBlocked` for the ORIGINAL question,
|
||||||
|
count `uncloak_blocked`, log (`blocked=true`, `"cname:"` reason), return.
|
||||||
|
15. Safe-search synthesis (ruling 10): new `ResponseBuilder` with the original question,
|
||||||
|
`addAnswer(original, CNAME, target)`, then each A/AAAA answer record of the upstream
|
||||||
|
response verbatim (owner = target name), `addOptEcho`. The upstream bytes are read from
|
||||||
|
`response_buf` while the builder writes into... **the builder needs its own buffer**: build
|
||||||
|
into a second half — concretely `handle` requires `response_buf.len >= 1024` already via
|
||||||
|
assert; S2 adds a `synth: [4096]u8` field to `Scratch` for the safe-search build, then
|
||||||
|
copies the finished reply into `response_buf`. A synthesized reply that cannot fit 4096 →
|
||||||
|
SERVFAIL (safe-search answers are tiny in practice).
|
||||||
|
16. Cache put: upstream path only, not blocked/safe-search (ruling 5): `classify` →
|
||||||
|
`cache_mutex` → `put`. Allocation failure inside put → skip caching (already
|
||||||
|
`Allocator.Error`; count nothing, put's stats cover it).
|
||||||
|
17. UDP truncation check (existing). Log the entry: `Entry.init(.{ .timestamp = now_s, .domain
|
||||||
|
= normalized domain, .client_ip = formatted from, .qtype, .blocked=false, .response_time_us,
|
||||||
|
.cache_hit=false, .upstream = per ruling 20 })`, `logger.log(io, entry)`. Return reply.
|
||||||
|
|
||||||
|
The query-log write happens on every replied path (blocked, local, forward, cache, upstream,
|
||||||
|
paused) — one call site per return is acceptable; a tiny private `logEntry` helper keeps it
|
||||||
|
readable. Refused/malformed/dropped are never logged (rulings 8, 20).
|
||||||
|
|
||||||
|
### S2.4 Acceptance
|
||||||
|
|
||||||
|
- [ ] `zig fmt --check` + `zig ast-check` clean; temp-root tests pass.
|
||||||
|
- [ ] Existing handler tests still pass (bare handler, null optionals).
|
||||||
|
- [ ] New in-file tests (fake `transport.Client` fixtures, as the existing tests do): REFUSED
|
||||||
|
on limit exhaustion; local A answer + NODATA; forward-zone match exchanges via the zone
|
||||||
|
resolver fake and bypasses a blocklisted name; blocked domain → zero-IP answer with
|
||||||
|
blocking TTL; allow-over-block passes; cache miss→put→hit with aged TTL + fresh ID;
|
||||||
|
blocked responses not cached; uncloak: CNAME chain to a blocked target at depth 1 and at
|
||||||
|
depth 8 blocks, depth 9 does not, `cname:` reason logged; safe-search: rewritten QNAME
|
||||||
|
goes upstream, reply carries original question + CNAME + A records only; ECS strip:
|
||||||
|
option 8 gone upstream, others preserved; ECS forward: payload lands in the cache key
|
||||||
|
(two subnets → two entries); paused: blocklisted name answers normally and counts;
|
||||||
|
non-IN bypasses filtering; null manager → unfiltered count; every path's log entry
|
||||||
|
fields per ruling 20 (capture via a Logger with a small queue, drain and assert).
|
||||||
|
|
||||||
|
### S2 As built
|
||||||
|
|
||||||
|
`Handler` gained one additive field: `negative_ttl_max: u32 = 0` (set from
|
||||||
|
`cfg.cache.negative_ttl_max` — `DnsCache` keeps no copy of its config and `classify` needs the
|
||||||
|
value; **S6 must wire it or negative caching is silently off**). `Scratch` gained `synth:
|
||||||
|
[4096]u8` (spec-blessed) and `uncloak: [types.max_name_len]u8` (target normalization must not
|
||||||
|
clobber `normalize`, which still holds the logged name). `Zones.empty` already existed
|
||||||
|
(forward_zones.zig:34) — the S2.2 parenthetical was stale. Both mutexes and the tracker use
|
||||||
|
`lockUncancelable` — `std.Io.Mutex.lock` returns `Cancelable!void` (Io.zig:1602) and `handle`
|
||||||
|
has no error union. Safe-search + ECS strip compose via two buffers: rebuild into
|
||||||
|
`scratch.build`, strip into `scratch.synth` (stripEcs requires non-overlapping output); `synth`
|
||||||
|
is reused for the reply after the upstream answers. Uncloaking is also skipped after a
|
||||||
|
safe-search rewrite (the answers belong to the provider's target, which the client never asked
|
||||||
|
about). `safesearch_rewrites` counts at the rebuild, not the table lookup. SERVFAIL paths are
|
||||||
|
counted but never query-logged (the Entry schema has no status field; a logged row would read
|
||||||
|
as success — accepted ruling). Forward zones are exempt from the ECS strip (accepted ruling:
|
||||||
|
the RFC 7871 concern is third-party upstreams, not the LAN resolver). Ruling 20's open choice:
|
||||||
|
`upstream = "pool"` for pool answers; forward-zone rows log `cache_hit` false/true (ruling 20's
|
||||||
|
"null" contradicted ruling 5 — null is now local records and blocked answers only). The
|
||||||
|
safe-search CNAME TTL is the minimum TTL of the copied A/AAAA records, zero when none.
|
||||||
|
`handle` keeps the 512-byte floor assert (the spec's "1024" was wrong); the copy out of
|
||||||
|
`synth` is length-checked with SERVFAIL fallback. `tracker_full` mirrors
|
||||||
|
`Tracker.snapshotStats().dropped_full` after each track (track returns nothing; an always-zero
|
||||||
|
counter would lie). `max_cname_depth = 8` public. `queries` still means pool-answered queries.
|
||||||
|
23 new handler tests + 25 adapted + 6 pause tests. After review, two ECS cache holes fixed at
|
||||||
|
the root: (1) a failed ECS strip forwards the client's subnet upstream, so the answer may be
|
||||||
|
subnet-specific while the `.strip`-mode key names no subnet — `outgoingQuery` returns
|
||||||
|
`Outgoing { bytes, cacheable }` and a failed strip sets `cacheable = false`, skipping both the
|
||||||
|
cache get and the put; (2) `.forward` mode forwards the whole OPT but `edns.findOption`
|
||||||
|
reports only the first ECS option, so a query carrying several would key on one subnet while
|
||||||
|
the resolver answered for another — a local `subnetForKey` walks the option list once and
|
||||||
|
returns `.uncacheable` for a repeated option 8, for a payload over `dns_cache.max_ecs_len`,
|
||||||
|
and for a list that will not walk; `cacheKey` then declines to cache. RFC 7871 §6 permits one
|
||||||
|
ECS option, but a query is client-controlled, so a duplicate is treated as hostile. Both fixes
|
||||||
|
are mutation-checked. `edns.ecsPayload` lost its only caller (the key needs the option count
|
||||||
|
as well as the payload; one walk yields both) and is removed from edns.zig as dead surface. NOTE: the plain `zig build test` does not
|
||||||
|
typecheck the listener call sites (serve is never analyzed there) — only `-Dintegration`
|
||||||
|
validates S3's edit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session S3: listeners pass the client address and scratch
|
||||||
|
|
||||||
|
Owns: `src/server/udp_server.zig`, `src/server/tcp_server.zig`, and the three integration test
|
||||||
|
files whose `Handler{…}` literals the S2 struct change broke:
|
||||||
|
`src/server/udp_server_integration_test.zig`, `src/server/tcp_server_integration_test.zig`,
|
||||||
|
`src/server/resolver_integration_test.zig` (S2 measured nine stale literals missing
|
||||||
|
`blocking`/`forward_read_timeout`/`records`/`zones` at udp:90/128/163/204, tcp:153/183/216/268,
|
||||||
|
resolver:229, plus the handle-arity change once those are fixed).
|
||||||
|
|
||||||
|
- `Slot` (udp) gains `scratch: handler.Scratch`; the call becomes
|
||||||
|
`self.handler.handle(io, .udp, address.NetAddress.fromIp(slot.from), slot.query[0..slot.len],
|
||||||
|
&slot.reply, &slot.scratch)`.
|
||||||
|
- `Conn` (tcp) gains `peer: std.Io.net.IpAddress` (captured at claim from
|
||||||
|
`stream.socket.address`) and `scratch: handler.Scratch`; call updated the same way.
|
||||||
|
- No other behavior change. Slot/Conn are pool-allocated already, so the added ~5 KiB per slot
|
||||||
|
changes no lifetime.
|
||||||
|
- Acceptance: fmt/ast clean; both files' existing tests updated and passing; integration tests
|
||||||
|
(`-Dintegration` udp/tcp/resolver) still pass unchanged in behavior.
|
||||||
|
|
||||||
|
### S3 As built
|
||||||
|
|
||||||
|
`Conn.peer` is captured in `claim` inside the same mutex-held section that stores the stream —
|
||||||
|
written once per connection, read only by that connection's task; verified from source that
|
||||||
|
`accept` fills `Socket.address` with the peer (net.zig:1442 → Threaded.zig:12439
|
||||||
|
`addressFromPosix`), and a new TCP integration test asserts the captured peer is the real
|
||||||
|
loopback client (an uninitialized field would typecheck and silently corrupt rate
|
||||||
|
limiting/grouping/logging). Importing the `address` module forced renaming the bind/listen
|
||||||
|
address parameters to `bind_address`/`listen_address` (Zig shadowing rule; call sites are
|
||||||
|
positional, nothing else changed). The nine stale integration literals became a per-file
|
||||||
|
`bareHandler` fixture deriving `blocking` from `model.Blocking{}` and `forward_read_timeout`
|
||||||
|
from `model.readTimeout(.{})` (3000 ms, `.awake`), so the fixtures cannot drift from config
|
||||||
|
defaults. Memory-cost comments updated for the scratch: UDP ≈74 KiB/slot (≈4.6 MiB at 64),
|
||||||
|
TCP ≈137 KiB/slot (≈8.8 MiB at 64); the UDP <8 MiB budget assertion still holds.
|
||||||
|
After review: `TcpServer.serve` distinguishes its two exit paths, because the composition root
|
||||||
|
cancels its task group before any listener `deinit` runs (the deinit defers are declared
|
||||||
|
first, so `group.cancel` executes first). `acceptLoop` returns a `Stop` enum: `.closing` when
|
||||||
|
`deinit` stopped it (`error.SocketNotListening`, a `.shutting_down` claim, or the state flag)
|
||||||
|
and `.canceled` when the task is being canceled (`error.Canceled` from `accept` or the retry
|
||||||
|
sleep). On `.closing` the connection group drains under `.blocked` protection as before —
|
||||||
|
`beginShutdown` already unblocked every live stream, so the wait is bounded by the shutdown.
|
||||||
|
On `.canceled` the group is canceled instead: nothing has shut the streams down, `deinit`
|
||||||
|
cannot run until `serve` returns, and RFC 7766 §6.2.1.1 lets a client hold a connection open
|
||||||
|
indefinitely by asking again inside the idle budget — draining would let one client stall
|
||||||
|
process shutdown. The cost is the single reply mid-write. The old await under `.blocked`
|
||||||
|
protection was what made cancel invisible: `Syscall.start` (Threaded.zig:1349) never arms
|
||||||
|
cancellation while protection is blocked. Per-connection cleanup is unaffected (`serveConn`'s
|
||||||
|
`defer finish` runs on the canceled path; `finish` already used `lockUncancelable` + `.blocked`
|
||||||
|
around the close). Pinned by "a canceled serve does not wait for a live connection" (blocked
|
||||||
|
read, 600 s idle budget, cancel must return; reinstating drain-always hangs the suite). The
|
||||||
|
same await shape remains in `UdpServer.serve` deliberately: a UDP task is one bounded upstream
|
||||||
|
exchange, not a client-paced session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session S4: client tracker + repo additions
|
||||||
|
|
||||||
|
Owns: `src/server/clients.zig` (new), `src/storage/repositories/clients_repo.zig` (extend).
|
||||||
|
|
||||||
|
### S4.1 clients_repo additions (SQL; existing functions frozen)
|
||||||
|
|
||||||
|
```zig
|
||||||
|
/// INSERT OR CONFLICT: materialize an unseen client (hand_edited=0, first_seen=last_seen=now)
|
||||||
|
/// or touch last_seen on an existing row (hand-edited rows get last_seen touched too — the
|
||||||
|
/// operator sees liveness; group/name/hand_edited never change here).
|
||||||
|
pub fn upsertSeen(database: *db.Db, ip: []const u8, now_s: i64) db.Error!void
|
||||||
|
/// DELETE hand_edited=0 AND last_seen < cutoff. Returns rows deleted.
|
||||||
|
pub fn pruneStale(database: *db.Db, cutoff_s: i64) db.Error!u32
|
||||||
|
```
|
||||||
|
|
||||||
|
`ON CONFLICT(ip) DO UPDATE SET last_seen=excluded.last_seen`. Check the actual clients schema
|
||||||
|
(unique key on ip) in storage/config_schema.zig and use the real column names. The existing
|
||||||
|
`insertClient` (hand_edited=1, import path) stays untouched.
|
||||||
|
|
||||||
|
### S4.2 Tracker
|
||||||
|
|
||||||
|
```zig
|
||||||
|
pub const Tracker = struct {
|
||||||
|
pub const max_pending = 512;
|
||||||
|
pub const flush_interval_s = 60;
|
||||||
|
pub const prune_every_passes = 1440; // daily at 60 s per pass
|
||||||
|
mutex: std.Io.Mutex, // guards the fixed table below
|
||||||
|
// fixed-capacity map, same house pattern as rate_limiter: slot array + index
|
||||||
|
pub fn init(retention_days: u32) Tracker
|
||||||
|
pub fn track(self: *Tracker, io: std.Io, addr: address.NetAddress) void // full → drop, count stats.dropped_full
|
||||||
|
pub fn run(self: *Tracker, io: std.Io, database: *db.Db, monitor: ?*disk_monitor.Monitor)
|
||||||
|
std.Io.Cancelable!void
|
||||||
|
pub const Stats = struct { tracked: u64, flushed: u64, dropped_full: u64, pruned: u64, flush_failures: u64 };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`run` is the house loop shape (manager.zig:946): startup no-op, then every `flush_interval_s`
|
||||||
|
on the `.boot` clock: skip the pass when `monitor` says writes are gated (ruling 17); else
|
||||||
|
drain the pending table under the mutex into a local copy (release before I/O), one
|
||||||
|
`upsertSeen` per client with `real` wall-clock seconds; every `prune_every_passes`-th pass also
|
||||||
|
`pruneStale(now - retention_days * 86_400)`. A failed upsert warns once per pass and counts
|
||||||
|
`flush_failures`; entries stay dropped (next query re-tracks). The database handle is the
|
||||||
|
loop's own dedicated config.db connection (ruling 21) — document on `run` like retention does.
|
||||||
|
IP text via `address.NetAddress.format` into a stack buffer.
|
||||||
|
|
||||||
|
Tests: track dedupes per flush (same client twice → one row, `last_seen` = latest), table
|
||||||
|
full drops and counts, flush inserts hand_edited=0 rows and touches existing hand-edited rows'
|
||||||
|
last_seen only, prune removes only stale hand_edited=0 rows, gated pass writes nothing
|
||||||
|
(in-memory db + a Monitor fixture — Monitor's classify/state accessors allow constructing a
|
||||||
|
gated state; if not constructible without I/O, test the gate branch through a bool seam
|
||||||
|
`writes_allowed` parameter on a private `flushOnce` and have `run` pass the monitor read).
|
||||||
|
|
||||||
|
Acceptance: fmt/ast clean; temp-root (with `-lc -lsqlite3`) green; migration untouched.
|
||||||
|
|
||||||
|
### S4 As built
|
||||||
|
|
||||||
|
`init(retention_days: u16)` (matches `model.Logging.retention_days`). The pending table is a
|
||||||
|
fixed 512-entry array with a linear scan, not a hash map — the frozen `init` takes no
|
||||||
|
allocator, and rate_limiter's actual pattern is a pre-reserved hash map, so "same house
|
||||||
|
pattern" did not apply; a linear scan over ≤512 entries of 17-byte keys is fine at household
|
||||||
|
scale. Added: `trackAt(io, addr, now_s)` (timestamp seam; `track` = `trackAt` with real
|
||||||
|
wall-clock), `flushOnce(io, database, writes_allowed: bool)` public (the gate seam `run` feeds
|
||||||
|
from `monitor.writesAllowed()`; S7 case 10 forces passes with it), `snapshotStats(io)` and
|
||||||
|
`pendingClients(io)` — `Stats` stays plain u64 and every field plus `passes` is written under
|
||||||
|
`mutex`, so readers go through the accessors. `last_seen` is stamped at track time (it means
|
||||||
|
"last query", and it is what makes latest-wins dedupe observable); the flush writes each
|
||||||
|
entry's own stamp. A gated pass does not increment `passes` (a skipped pass must not advance
|
||||||
|
the prune schedule). `track` uses `lockUncancelable` — `handle` has no error union, so `track`
|
||||||
|
is not a cancelation point. `upsertSeen` resolves `group_id` via
|
||||||
|
`(SELECT id FROM groups WHERE name = 'default')` — the column is NOT NULL REFERENCES groups
|
||||||
|
(config_schema.zig:26), which ruling 14's sketch omitted; a database with no `default` group
|
||||||
|
fails the whole statement with `error.Constraint` and writes nothing (validate.zig:461 already
|
||||||
|
rejects such configs, so this is a warn-and-continue path in the flush loop, never live). The
|
||||||
|
upsert's DO UPDATE touches `last_seen` only — `name`, `group_id`, `hand_edited`, `first_seen`
|
||||||
|
survive on every existing row, hand-edited or not, one test per column. `pruneStale` uses
|
||||||
|
strict `<` (matches `queries_repo.pruneOlderThan`) and clamps `sqlite3_changes` to u32.
|
||||||
|
16 tests (9 tracker, 7 repo).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session S6: composition root — app.zig, shutdown.zig, cli wiring
|
||||||
|
|
||||||
|
Owns: `src/app.zig` (new), `src/server/shutdown.zig` (new), `src/cli.zig` (runRun body,
|
||||||
|
DataDir), `src/main.zig` (only if a signature forces it — expected unchanged).
|
||||||
|
|
||||||
|
### S6.1 shutdown.zig
|
||||||
|
|
||||||
|
```zig
|
||||||
|
var event: std.Io.Event = .unset;
|
||||||
|
var event_io: ?std.Io = null; // set once by install before handlers exist
|
||||||
|
pub fn install(io: std.Io) void // sigaction INT+TERM → handler calls event.set(event_io.?)
|
||||||
|
pub fn wait(io: std.Io) std.Io.Cancelable!void // event.wait
|
||||||
|
pub fn trigger(io: std.Io) void // tests and future admin use: event.set
|
||||||
|
```
|
||||||
|
|
||||||
|
Handler fn: `callconv(.c)`, calls set only (async-signal-safe per ruling 2). `flags = 0`, empty
|
||||||
|
mask (matches Threaded's own handlers, Threaded.zig:1653). Do not restore handlers on exit —
|
||||||
|
the process is leaving anyway; `install` is once-per-process (assert).
|
||||||
|
|
||||||
|
### S6.2 app.zig — `pub fn run(runner: cli.Runner, paths: cli.Paths) u8`
|
||||||
|
|
||||||
|
Startup order (each failure prints to `runner.err` and returns `cli.exit_runtime`):
|
||||||
|
|
||||||
|
1. `DataDir.open` (S6 changes its `openDir` flags to `.{ .iterate = true }` — the monitor
|
||||||
|
needs it; nothing else cares). `openConfigDb`. `migrations.migrate`.
|
||||||
|
`bootstrap.bootstrap(...)` (first run seeds; diags printed like runCheck does).
|
||||||
|
`export.readConfig` into an arena.
|
||||||
|
2. `logging.install(io, cfg.logging, ...)` — the sink goes live (main.zig's std_options
|
||||||
|
already routes).
|
||||||
|
3. Build state, in dependency order, all heap-allocated where pinning is required (Logger,
|
||||||
|
Manager, Pool entries and their DoH/DoT client structs must not move — same pattern as
|
||||||
|
`cli.probeUpstreams` but with owned arrays that outlive the pool):
|
||||||
|
records + zones (`local_repo` listers → `build`), fetcher + manager (`Manager.init`),
|
||||||
|
pool (upstreams_repo → clients array → `Pool.init`), cache (`DnsCache.init`), limiter
|
||||||
|
(`RateLimiter.init` from `cfg.dns`), pause, tracker, logger (`Logger.init` with a
|
||||||
|
heap `queue_buf` of `cfg.logging.query_log_buffer_max` entries), monitor (`Monitor.init`
|
||||||
|
with the data dir), retention (`Retention.init`), querylog connections: add
|
||||||
|
`DataDir.openQuerylogDb(self, io) !db.Db` mirroring openConfigDb (same pragmas + 0600);
|
||||||
|
open TWO (writer, retention) plus ONE extra config.db connection for the tracker
|
||||||
|
(ruling 21).
|
||||||
|
4. One synchronous `manager.reload(io)`; failure warns and continues (ruling 4).
|
||||||
|
5. Handler construction (S2 struct) + `UdpServer.bind` + `TcpServer.listen` on
|
||||||
|
`cfg.dns.bind_ipv4/bind_ipv6/port` — v4 and v6 both, matching PLAN §7.2 parity; a bind
|
||||||
|
failure is fatal.
|
||||||
|
6. `shutdown.install(io)`. Then one `std.Io.Group`:
|
||||||
|
udp.serve, tcp.serve, logger.runWriter(io, &querylog_writer_db, &monitor),
|
||||||
|
retention.run(io, &querylog_retention_db), monitor.run(io),
|
||||||
|
manager.runScheduler(io), tracker.run(io, &tracker_db, &monitor),
|
||||||
|
and a maintenance loop (private fn in app.zig, house shape, `.boot`, 60 s): cache sweep +
|
||||||
|
limiter sweep under their mutexes... the mutexes live in the Handler; the maintenance loop
|
||||||
|
takes `*Handler` and locks the same mutexes.
|
||||||
|
7. `shutdown.wait(io)` (a Canceled result also proceeds to teardown). Then ruling 22 order:
|
||||||
|
`logger.shutdown(io)` → `group.cancel(io)` → server deinits → pool/manager/state deinits →
|
||||||
|
DB closes → DataDir.close → return `cli.exit_ok`.
|
||||||
|
|
||||||
|
Log a one-line startup summary (scope `.nxdns`, info): bind addresses, snapshot generation or
|
||||||
|
"unfiltered", upstream count.
|
||||||
|
|
||||||
|
### S6.3 cli.zig
|
||||||
|
|
||||||
|
`runRun` body: `return app.run(r, paths);` — keep the doc comment honest. `DataDir.open` flag
|
||||||
|
change + `openQuerylogDb` as above (both used by app; runCheck untouched).
|
||||||
|
|
||||||
|
### S6.4 Acceptance
|
||||||
|
|
||||||
|
- [ ] fmt/ast clean on all owned files; `zig build` (exe) succeeds — app.zig is reached from
|
||||||
|
main, so full semantic analysis covers it.
|
||||||
|
- [ ] `zig build test` green (existing cli tests untouched; app.zig gets a smoke test only if
|
||||||
|
expressible without sockets — otherwise S7 owns runtime coverage; state that choice).
|
||||||
|
- [ ] Manual smoke (orchestrator runs it): `nxdns run` against a scratch data dir starts,
|
||||||
|
answers `dig @127.0.0.1 -p <port>`, and exits 0 on SIGTERM promptly.
|
||||||
|
|
||||||
|
### S6 As built
|
||||||
|
|
||||||
|
Startup order as specified, with these corrections. **Binding**: IPv6 binds first; an
|
||||||
|
`AddressInUse` from the IPv4 bind that follows a wildcard IPv6 bind is dual-stack coverage
|
||||||
|
(info line), not a failure — on Linux `net.ipv6.bindv6only=0` makes the v6 wildcard own the v4
|
||||||
|
port, and `BindOptions.ip6_only` cannot prevent it because Threaded.zig:12274 sets IPV6_V6ONLY
|
||||||
|
to 0 when the option is true (inverted vs its doc); a foreign IPv4 holder would have failed
|
||||||
|
the v6 wildcard bind too, so the inference is sound. `NetAddress.fromIp` unmaps `::ffff:`
|
||||||
|
clients, so v4 clients keep their real identity. A kernel without IPv6
|
||||||
|
(`AddressFamilyUnsupported` and friends) warns and serves IPv4; every other bind failure is
|
||||||
|
fatal. **Exit codes**: configuration faults (`NoUsableUpstreams`, `BadBindAddress`,
|
||||||
|
`BadRateLimit` — zero rate fields are rejected before RateLimiter.init asserts) exit
|
||||||
|
`cli.exit_check` with a "run 'nxdns check'" hint; everything else exits `cli.exit_runtime`.
|
||||||
|
**Querylog open**: `openQuerylogDb` wraps `querylog_schema.open` (creation/repair lives there;
|
||||||
|
chmod of the file plus both WAL sidecars happens after, missing sidecars ignored);
|
||||||
|
`reopenQuerylogDb` opens the extra connections without repeating the O(size) quick_check.
|
||||||
|
**Two `std.http.Client`s**: blocklist downloads must not queue DoH queries behind a
|
||||||
|
multi-megabyte stream. Teardown is defer-driven; the invariants held are: group.cancel joins
|
||||||
|
first, config.db closes after manager.deinit, DataDir closes last. `negative_ttl_max` and
|
||||||
|
`forward_read_timeout` are wired (the S2 trap). `shutdown.zig` has `reset()` for tests and 2
|
||||||
|
in-file tests (orchestrator wired it into tests.zig — app.zig analysis via cli does not
|
||||||
|
collect tests). app.zig itself has no unit test; S7 case 11 is the runtime proof. Smoke test
|
||||||
|
passed: boot, migration 0->2, dual-stack answers on 15353/15354, SERVFAIL from the dead
|
||||||
|
upstream, SIGTERM exit 0 in 0.007 s, DBs 0600, zero err lines. The manager disk gate was wired in a follow-up S6 edit — see ruling 17's as-built text; the
|
||||||
|
gate test drives `Monitor.state_raw` directly (no I/O) and pins no-monitor/ok/warn pass,
|
||||||
|
critical gates-and-counts, recovery resumes. After review: the composition root takes one
|
||||||
|
synchronous `monitor.sample(io)` after `Monitor.init`, before the `Io.Group` spawns anything —
|
||||||
|
`Monitor` initializes to `.ok` and `run` samples inside its own task, so without it the
|
||||||
|
writer/tracker/scheduler would consult a gate still saying "writes allowed" during the boot
|
||||||
|
window on a critically full disk. A failed first sample warns and leaves `.ok` (an unreadable
|
||||||
|
filesystem is not evidence of a full disk — the monitor's own documented policy). `run`'s own
|
||||||
|
first sample repeats it (one extra statvfs + scan at boot; `run`'s sample-then-sleep contract
|
||||||
|
stays untouched). The ordering is not unit-testable (serve binds sockets and opens four
|
||||||
|
databases); S7's boot case plus the statement order is the evidence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session S7: phase 7 integration tests
|
||||||
|
|
||||||
|
Owns: `src/server/phase7_integration_test.zig` (new; gated on `build_options.integration` like
|
||||||
|
phase6). Fixtures may reuse the S8/M6 helper patterns (in-memory dbs, fake upstream sockets).
|
||||||
|
|
||||||
|
Cases (each end-to-end through a real `Handler` with real UDP sockets on 127.0.0.1 ephemeral
|
||||||
|
ports; a fake upstream = a UDP socket task answering canned responses):
|
||||||
|
|
||||||
|
1. Blocked domain answers 0.0.0.0 with blocking TTL; log entry captured with reason.
|
||||||
|
2. Allow-rule-over-blocklist resolves via upstream.
|
||||||
|
3. Local record answers authoritatively without touching the fake upstream.
|
||||||
|
4. Forward zone: matching suffix goes to the zone resolver socket (not the pool fake), a
|
||||||
|
blocklisted name inside the zone still resolves (bypass), response cached (second query
|
||||||
|
does not hit the zone socket).
|
||||||
|
5. Cache: miss→hit, TTL aged, transaction ID fresh, `cache_hit` logged on the hit.
|
||||||
|
6. Uncloak: fake upstream returns CNAME chain to a blocked target → zero-IP answer with
|
||||||
|
`cname:` reason.
|
||||||
|
7. Safe-search group: query for a safesearch.zig table domain returns CNAME + the fake
|
||||||
|
upstream's A for the target; question is the original name.
|
||||||
|
8. Rate limit: limit=2 window=60 → third query REFUSED.
|
||||||
|
9. Pause: pauseFor(indefinite) lets a blocked domain resolve; unpause blocks it again.
|
||||||
|
10. Tracker: after queries + one forced flush (`flushOnce` seam or short interval), the
|
||||||
|
clients table holds the source IP with hand_edited=0.
|
||||||
|
11. Full app boot: `app.run` in a task against a temp data dir (bootstrap from a minimal
|
||||||
|
config.zon on an ephemeral port), one real resolve through it, `shutdown.trigger`, task
|
||||||
|
returns 0. This is the lifecycle proof.
|
||||||
|
|
||||||
|
Acceptance: `zig build test -Dintegration` green, zero std.log.err emitted (binding logging
|
||||||
|
policy), cases deterministic (no external network — the pool's upstream is the fake).
|
||||||
|
|
||||||
|
### S7 As built
|
||||||
|
|
||||||
|
Case 11 runs against a `std.testing.tmpDir` rather than a fixed scratch path (a committed
|
||||||
|
test cannot hold a machine-specific absolute path; the phase6 fixture already resolves
|
||||||
|
cwd-relative paths through both `std.Io.Dir` and SQLite). Port 15455, IPv4+IPv6 binds, the
|
||||||
|
dead `https://127.0.0.1:9/dns-query` upstream, one seeded local record; `app.serve` installs
|
||||||
|
and deinstalls its own log sink, so `installForTest` is unnecessary and the case asserts the
|
||||||
|
error writer stayed empty; `shutdown.reset()` brackets the case. Case 5 proves TTL ageing
|
||||||
|
with an entry planted ten seconds in the past under the handler's own key — the
|
||||||
|
miss-hit-fresh-ID half is a real round trip; no test can advance the clock. Case 4's zone
|
||||||
|
resolver is a UDP socket task answering with `ResponseBuilder` against the received query;
|
||||||
|
the second query proves the cache by leaving the resolver's datagram count at 1. The fake
|
||||||
|
pool upstream builds each answer from the question it receives (safe search and ECS rewrite
|
||||||
|
the outgoing question, so canned bytes would not bind). A shared `Loop` fixture binds the
|
||||||
|
listener and client before the serve task starts, so no task holds a pointer into a value
|
||||||
|
that later moves. 11 tests, all gated on `build_options.integration`; the integration suite
|
||||||
|
emits zero err-level lines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module layout (new/changed)
|
||||||
|
|
||||||
|
| file | change |
|
||||||
|
|---|---|
|
||||||
|
| src/dns/edns.zig | +stripEcs (S1; ecsPayload added then removed in review) |
|
||||||
|
| src/server/handler.zig | pipeline rewrite (S2) |
|
||||||
|
| src/server/pause.zig | new (S2) |
|
||||||
|
| src/server/udp_server.zig, tcp_server.zig | address + scratch plumbing (S3) |
|
||||||
|
| src/server/clients.zig | new tracker (S4) |
|
||||||
|
| src/storage/repositories/clients_repo.zig | +upsertSeen, +pruneStale (S4) |
|
||||||
|
| src/app.zig | new composition root (S6) |
|
||||||
|
| src/server/shutdown.zig | new (S6) |
|
||||||
|
| src/cli.zig | runRun body, DataDir.iterate, openQuerylogDb (S6) |
|
||||||
|
| src/server/phase7_integration_test.zig | new (S7) |
|
||||||
|
|
||||||
|
## File ownership
|
||||||
|
|
||||||
|
S1: dns/edns.zig. S2: server/handler.zig, server/pause.zig. S3: server/udp_server.zig,
|
||||||
|
server/tcp_server.zig. S4: server/clients.zig, storage/repositories/clients_repo.zig.
|
||||||
|
S6: app.zig, server/shutdown.zig, cli.zig, main.zig(if forced). S7: its test file.
|
||||||
|
Orchestrator: src/tests.zig, specs. No parallel sessions share a file (S1∥S4; the rest are
|
||||||
|
sequential).
|
||||||
|
|
||||||
|
## Acceptance (milestone complete)
|
||||||
|
|
||||||
|
- [ ] `zig build test` and `zig build test -Dintegration` green; `zig fmt --check` clean;
|
||||||
|
`zig build cross` produces both static exes.
|
||||||
|
- [ ] All S7 cases pass; the app boots, serves, and shuts down cleanly (case 11).
|
||||||
|
- [ ] No std.log.err in any new code path exercised by tests (binding logging policy:
|
||||||
|
err is reserved for swallowed failures).
|
||||||
|
- [ ] Spec As-built sections synced per session.
|
||||||
|
|
||||||
|
## Anti-requirements
|
||||||
|
|
||||||
|
- No HTTP/SSE/metrics/auth (Phase 8). No DoH/DoT server (Phase 9). No packaging (Phase 10).
|
||||||
|
- No regex rules, DHCP, DNSSEC validation, DoQ, clustering (PLAN §2.2).
|
||||||
|
- No per-group cache, no cache persistence, no query re-resolution in uncloaking.
|
||||||
|
- No new config fields, no schema migration, no pause persistence.
|
||||||
|
- No benchmark harness this milestone.
|
||||||
|
- dns/ stays pure (S1's additions are pure); filter/local/cache modules are not edited.
|
||||||
+642
@@ -0,0 +1,642 @@
|
|||||||
|
//! The composition root: everything `nxdns run` owns, in the order it is built.
|
||||||
|
//!
|
||||||
|
//! Nothing else in the program constructs a collaborator. Each module takes its
|
||||||
|
//! dependencies as parameters and knows nothing about who supplies them, so
|
||||||
|
//! this file is the only place where the object graph exists — and the only
|
||||||
|
//! place a lifetime spans the whole process.
|
||||||
|
//!
|
||||||
|
//! Two rules shape it:
|
||||||
|
//!
|
||||||
|
//! 1. **Nothing that is pointed at may move.** `Pool.Entry.client` erases a
|
||||||
|
//! pointer into a DoH or DoT client, the `Logger`'s queue holds waiting
|
||||||
|
//! tasks in intrusive lists, and the `Manager` is addressed through `self`.
|
||||||
|
//! Every such value therefore lives in `serve`'s frame or in an owned heap
|
||||||
|
//! allocation, never in a temporary that is copied afterwards.
|
||||||
|
//! 2. **Every background loop is a task in one `std.Io.Group`.** Shutdown is
|
||||||
|
//! `group.cancel`, which requests cancellation and joins, so no loop can
|
||||||
|
//! still be running when the resources it borrows are released.
|
||||||
|
//!
|
||||||
|
//! Failure policy: a startup failure prints one line to the runner's error
|
||||||
|
//! writer and exits. A configuration the operator can fix exits 2, so that
|
||||||
|
//! `nxdns check` is the obvious next step; anything else exits 1. Once serving
|
||||||
|
//! starts, nothing is fatal — the pipeline answers queries with a snapshot or
|
||||||
|
//! without one (ruling 4), and every loop logs its own failures and keeps
|
||||||
|
//! going.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
|
const Certificate = std.crypto.Certificate;
|
||||||
|
const Writer = std.Io.Writer;
|
||||||
|
const net = std.Io.net;
|
||||||
|
const tls = std.crypto.tls;
|
||||||
|
|
||||||
|
const bootstrap = @import("config/bootstrap.zig");
|
||||||
|
const cli = @import("cli.zig");
|
||||||
|
const clients = @import("server/clients.zig");
|
||||||
|
const config_export = @import("config/export.zig");
|
||||||
|
const disk_monitor = @import("storage/disk_monitor.zig");
|
||||||
|
const dns_cache = @import("cache/dns_cache.zig");
|
||||||
|
const doh_client = @import("upstream/doh_client.zig");
|
||||||
|
const dot_client = @import("upstream/dot_client.zig");
|
||||||
|
const fetcher = @import("filter/fetcher.zig");
|
||||||
|
const forward_zones = @import("local/forward_zones.zig");
|
||||||
|
const handler = @import("server/handler.zig");
|
||||||
|
const local_records = @import("local/records.zig");
|
||||||
|
const logger_mod = @import("storage/logger.zig");
|
||||||
|
const logging = @import("platform/logging.zig");
|
||||||
|
const manager_mod = @import("filter/manager.zig");
|
||||||
|
const migrations = @import("storage/migrations.zig");
|
||||||
|
const model = @import("config/model.zig");
|
||||||
|
const pause = @import("server/pause.zig");
|
||||||
|
const pool_mod = @import("upstream/pool.zig");
|
||||||
|
const rate_limiter = @import("server/rate_limiter.zig");
|
||||||
|
const retention_mod = @import("storage/retention.zig");
|
||||||
|
const shutdown = @import("server/shutdown.zig");
|
||||||
|
const tcp_server = @import("server/tcp_server.zig");
|
||||||
|
const transport = @import("upstream/transport.zig");
|
||||||
|
const udp_server = @import("server/udp_server.zig");
|
||||||
|
const validate = @import("config/validate.zig");
|
||||||
|
const version = @import("version.zig");
|
||||||
|
|
||||||
|
const log = std.log.scoped(.nxdns);
|
||||||
|
|
||||||
|
/// How long the cache and rate-limiter sweeps wait between passes. Both tables
|
||||||
|
/// evict lazily on lookup as well; this is what keeps memory bounded for a
|
||||||
|
/// client that asked once and never came back.
|
||||||
|
const maintenance_interval_s = 60;
|
||||||
|
|
||||||
|
/// Bounds one blocklist download (`Manager.total_budget`). Generous, because a
|
||||||
|
/// megabyte-scale list over a household uplink is normal, and a download that
|
||||||
|
/// takes longer than this is not going to finish at all.
|
||||||
|
const download_budget_s = 300;
|
||||||
|
|
||||||
|
/// Per DoH upstream. `min_request_buf` is 512; the extra room costs nothing and
|
||||||
|
/// keeps a maximum-length name with a large OPT record comfortable.
|
||||||
|
const doh_request_buf_len = 1024;
|
||||||
|
const doh_transfer_buf_len = 4096;
|
||||||
|
|
||||||
|
/// A configuration fault the operator can fix, as opposed to a runtime one.
|
||||||
|
/// These are the only failures this file raises itself; everything else comes
|
||||||
|
/// out of a collaborator.
|
||||||
|
const ConfigError = error{
|
||||||
|
NoUsableUpstreams,
|
||||||
|
BadBindAddress,
|
||||||
|
BadRateLimit,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn run(runner: cli.Runner, paths: cli.Paths) u8 {
|
||||||
|
const code = serve(runner, paths) catch |err| code: {
|
||||||
|
runner.err.print("nxdns run failed: {s}\n", .{@errorName(err)}) catch {};
|
||||||
|
if (isConfigFault(err)) {
|
||||||
|
runner.err.writeAll("run `nxdns check` to see the configuration in full\n") catch {};
|
||||||
|
break :code cli.exit_check;
|
||||||
|
}
|
||||||
|
break :code cli.exit_runtime;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Output the operator never received is not output, so a failed flush
|
||||||
|
// outranks a successful run — the same rule `cli.finish` applies.
|
||||||
|
runner.out.flush() catch return cli.exit_runtime;
|
||||||
|
runner.err.flush() catch return cli.exit_runtime;
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn isConfigFault(err: anyerror) bool {
|
||||||
|
return switch (err) {
|
||||||
|
error.NoUsableUpstreams, error.BadBindAddress, error.BadRateLimit => true,
|
||||||
|
else => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve(r: cli.Runner, paths: cli.Paths) !u8 {
|
||||||
|
const io = r.io;
|
||||||
|
const gpa = r.gpa;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// storage and configuration
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
var data = try cli.DataDir.open(io, gpa, paths.data_dir, true);
|
||||||
|
defer data.close(io, gpa);
|
||||||
|
|
||||||
|
var config_db = try data.openConfigDb(io);
|
||||||
|
defer config_db.close();
|
||||||
|
_ = try migrations.migrate(&config_db);
|
||||||
|
|
||||||
|
{
|
||||||
|
// First run only: the file seeds an empty database and is ignored
|
||||||
|
// forever after. Its diagnostics are the operator's one chance to see
|
||||||
|
// why a config file was rejected, so they are printed like `check`
|
||||||
|
// prints them.
|
||||||
|
var diags: validate.Diagnostics = .init(gpa);
|
||||||
|
defer diags.deinit();
|
||||||
|
_ = bootstrap.bootstrap(io, gpa, &config_db, std.Io.Dir.cwd(), paths.config, &diags) catch |err| {
|
||||||
|
diags.writeAll(r.err) catch {};
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every string in `cfg` points into this arena, and the pool's endpoints,
|
||||||
|
// the handler's records and the monitor's paths all keep such strings. It
|
||||||
|
// therefore outlives all of them, which is why it is declared here and not
|
||||||
|
// inside a narrower scope.
|
||||||
|
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const arena = arena_state.allocator();
|
||||||
|
|
||||||
|
const cfg = try config_export.readConfig(&config_db, arena);
|
||||||
|
|
||||||
|
// From here on `std.log` goes wherever the operator asked. Before this call
|
||||||
|
// the sink passes through to stderr, which is where a failure above needs
|
||||||
|
// to appear anyway.
|
||||||
|
logging.install(io, cfg.logging);
|
||||||
|
defer logging.deinstall();
|
||||||
|
|
||||||
|
if (cfg.dns.rate_limit == 0 or cfg.dns.rate_window_seconds == 0) return error.BadRateLimit;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// local answers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
var records = try local_records.Records.build(gpa, cfg.local_records);
|
||||||
|
defer records.deinit(gpa);
|
||||||
|
|
||||||
|
var zones = try forward_zones.Zones.build(gpa, cfg.forward_zones);
|
||||||
|
defer zones.deinit(gpa);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// blocklists
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Two HTTP clients on purpose. A blocklist download streams tens of
|
||||||
|
// megabytes and holds its connection for the whole of it; DoH queries must
|
||||||
|
// not queue behind that, and the two have nothing to share but a type.
|
||||||
|
var fetch_http: std.http.Client = .{ .allocator = gpa, .io = io };
|
||||||
|
defer fetch_http.deinit();
|
||||||
|
var dns_http: std.http.Client = .{ .allocator = gpa, .io = io };
|
||||||
|
defer dns_http.deinit();
|
||||||
|
|
||||||
|
const fetch_transfer = try gpa.alloc(u8, fetcher.min_transfer_buf);
|
||||||
|
defer gpa.free(fetch_transfer);
|
||||||
|
const fetch_redirect = try gpa.alloc(u8, fetcher.redirect_buffer_len);
|
||||||
|
defer gpa.free(fetch_redirect);
|
||||||
|
var fetch: fetcher.Fetcher = .{
|
||||||
|
.http = &fetch_http,
|
||||||
|
.transfer_buf = fetch_transfer,
|
||||||
|
.redirect_buf = fetch_redirect,
|
||||||
|
};
|
||||||
|
|
||||||
|
var manager: manager_mod.Manager = try .init(
|
||||||
|
gpa,
|
||||||
|
&config_db,
|
||||||
|
.{ .dir = data.dir },
|
||||||
|
&fetch,
|
||||||
|
cfg.blocklist_update,
|
||||||
|
.{ .raw = .fromSeconds(download_budget_s), .clock = .awake },
|
||||||
|
);
|
||||||
|
defer manager.deinit(io);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// upstream pool
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Shared by every DoT client: the trust store does not vary per upstream
|
||||||
|
// and the scan is expensive. Both must outlive the clients that hold them.
|
||||||
|
var bundle: Certificate.Bundle = .empty;
|
||||||
|
defer bundle.deinit(gpa);
|
||||||
|
var bundle_lock: std.Io.RwLock = .init;
|
||||||
|
|
||||||
|
var upstreams = try Upstreams.build(gpa, cfg.upstreams, &dns_http, &bundle, &bundle_lock);
|
||||||
|
defer upstreams.deinit(gpa);
|
||||||
|
|
||||||
|
var pool: pool_mod.Pool = .init(
|
||||||
|
upstreams.active(),
|
||||||
|
.{},
|
||||||
|
.{ .raw = model.totalTimeout(cfg.upstream), .clock = .awake },
|
||||||
|
@truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
|
||||||
|
);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// per-query state
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
var cache: dns_cache.DnsCache = try .init(gpa, cfg.cache);
|
||||||
|
defer cache.deinit();
|
||||||
|
|
||||||
|
var limiter: rate_limiter.RateLimiter = try .init(gpa, .{
|
||||||
|
.limit = cfg.dns.rate_limit,
|
||||||
|
.window_seconds = cfg.dns.rate_window_seconds,
|
||||||
|
});
|
||||||
|
defer limiter.deinit();
|
||||||
|
|
||||||
|
var paused: pause.Pause = .{};
|
||||||
|
var tracker: clients.Tracker = .init(cfg.logging.retention_days);
|
||||||
|
|
||||||
|
// The queue holds waiting tasks in intrusive lists, so neither the buffer
|
||||||
|
// nor the `Logger` may move once a task has touched either.
|
||||||
|
const queue_buf = try gpa.alloc(logger_mod.Entry, cfg.logging.query_log_buffer_max);
|
||||||
|
defer gpa.free(queue_buf);
|
||||||
|
var query_logger: logger_mod.Logger = .init(cfg.logging, queue_buf);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// disk, retention and the remaining connections (ruling 21)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const data_path = try arena.dupeZ(u8, paths.data_dir);
|
||||||
|
const log_dir_path: ?[:0]const u8 = if (cfg.logging.output == .file)
|
||||||
|
try arena.dupeZ(u8, std.fs.path.dirname(cfg.logging.file_path) orelse ".")
|
||||||
|
else
|
||||||
|
null;
|
||||||
|
var monitor: disk_monitor.Monitor = .init(cfg.disk, data.dir, data_path, log_dir_path);
|
||||||
|
|
||||||
|
// Ruling 17. The scheduler consults it before every scheduled pass; the
|
||||||
|
// startup `reload` below is an operator action and stays ungated.
|
||||||
|
manager.monitor = &monitor;
|
||||||
|
|
||||||
|
// One synchronous sample before anything can consult the gate. `Monitor`
|
||||||
|
// initializes to `.ok`, and `Monitor.run` takes its first sample inside the
|
||||||
|
// task — so without this call the log writer, the tracker and the blocklist
|
||||||
|
// scheduler would all see "writes allowed" during the boot window and could
|
||||||
|
// write to a critically full disk before the gate ever reflected it.
|
||||||
|
// `run`'s own first sample repeats this one, which costs one extra statvfs
|
||||||
|
// and directory scan at startup and keeps `run`'s "sample first, then
|
||||||
|
// sleep" contract intact.
|
||||||
|
//
|
||||||
|
// `sample` returns nothing: every failure warns and counts inside the
|
||||||
|
// monitor, and leaves the previous reading in place. A first sample that
|
||||||
|
// fails therefore leaves the state at `.ok` — an unreadable filesystem is
|
||||||
|
// not evidence that the disk is full, which is the monitor's documented
|
||||||
|
// policy and the right one here too.
|
||||||
|
monitor.sample(io);
|
||||||
|
|
||||||
|
var retention: retention_mod.Retention = .init(cfg.logging);
|
||||||
|
|
||||||
|
var querylog_writer_db = try data.openQuerylogDb(io);
|
||||||
|
defer querylog_writer_db.close();
|
||||||
|
var querylog_retention_db = try data.reopenQuerylogDb(io);
|
||||||
|
defer querylog_retention_db.close();
|
||||||
|
var tracker_db = try data.openConfigDb(io);
|
||||||
|
defer tracker_db.close();
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// first snapshot
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Ruling 4: serving starts either way. A household loses more from a DNS
|
||||||
|
// server that refuses to start than from one that answers unfiltered until
|
||||||
|
// the scheduler's first pass succeeds.
|
||||||
|
manager.reload(io) catch |err| {
|
||||||
|
log.warn(
|
||||||
|
"loading the blocklist snapshot failed ({s}); serving unfiltered until the next refresh",
|
||||||
|
.{@errorName(err)},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// listeners
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
var h: handler.Handler = .{
|
||||||
|
.upstream = pool.client(),
|
||||||
|
.blocking = .{ .mode = cfg.blocking.response, .ttl = cfg.blocking.ttl },
|
||||||
|
.ecs_mode = cfg.edns.ecs_mode,
|
||||||
|
.forward_read_timeout = .{ .raw = model.readTimeout(cfg.upstream), .clock = .awake },
|
||||||
|
.manager = &manager,
|
||||||
|
.records = &records,
|
||||||
|
.zones = &zones,
|
||||||
|
.cache = &cache,
|
||||||
|
.negative_ttl_max = cfg.cache.negative_ttl_max,
|
||||||
|
.limiter = &limiter,
|
||||||
|
.logger = &query_logger,
|
||||||
|
.pause = &paused,
|
||||||
|
.tracker = &tracker,
|
||||||
|
};
|
||||||
|
|
||||||
|
const v6_bind = parseBind(r, cfg.dns.bind_ipv6, cfg.dns.port, "dns.bind_ipv6") catch |err| return err;
|
||||||
|
const v4_bind = parseBind(r, cfg.dns.bind_ipv4, cfg.dns.port, "dns.bind_ipv4") catch |err| return err;
|
||||||
|
|
||||||
|
// IPv6 first, and the order is load-bearing — see `Listeners`.
|
||||||
|
var udp6: ?udp_server.UdpServer = udp_server.UdpServer.bind(gpa, io, v6_bind, &h, .{}) catch |err| bound: {
|
||||||
|
if (!ipv6Unavailable(err)) return reportBind(r, "udp", v6_bind, err);
|
||||||
|
log.warn("this system has no IPv6; serving IPv4 only", .{});
|
||||||
|
break :bound null;
|
||||||
|
};
|
||||||
|
defer if (udp6) |*s| s.deinit(gpa, io);
|
||||||
|
|
||||||
|
const v6_is_wildcard = udp6 != null and isWildcard(v6_bind);
|
||||||
|
|
||||||
|
var udp4: ?udp_server.UdpServer = udp_server.UdpServer.bind(gpa, io, v4_bind, &h, .{}) catch |err| bound: {
|
||||||
|
if (err != error.AddressInUse or !v6_is_wildcard) return reportBind(r, "udp", v4_bind, err);
|
||||||
|
log.info("the IPv6 UDP listener is dual-stack and already serves IPv4", .{});
|
||||||
|
break :bound null;
|
||||||
|
};
|
||||||
|
defer if (udp4) |*s| s.deinit(gpa, io);
|
||||||
|
|
||||||
|
var tcp6: ?tcp_server.TcpServer = tcp_server.TcpServer.listen(gpa, io, v6_bind, &h, .{}) catch |err| bound: {
|
||||||
|
if (!ipv6Unavailable(err)) return reportBind(r, "tcp", v6_bind, err);
|
||||||
|
break :bound null;
|
||||||
|
};
|
||||||
|
defer if (tcp6) |*s| s.deinit(gpa, io);
|
||||||
|
|
||||||
|
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))) {
|
||||||
|
return reportBind(r, "tcp", v4_bind, err);
|
||||||
|
}
|
||||||
|
log.info("the IPv6 TCP listener is dual-stack and already serves IPv4", .{});
|
||||||
|
break :bound null;
|
||||||
|
};
|
||||||
|
defer if (tcp4) |*s| s.deinit(gpa, io);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// run
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
shutdown.install(io);
|
||||||
|
|
||||||
|
// Declared after everything it borrows, so its `cancel` — which both
|
||||||
|
// requests cancellation and joins — is the first thing that runs on the way
|
||||||
|
// out (ruling 22). Nothing below this line may be released while a task
|
||||||
|
// could still touch it.
|
||||||
|
var group: std.Io.Group = .init;
|
||||||
|
defer group.cancel(io);
|
||||||
|
|
||||||
|
if (udp6) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io });
|
||||||
|
if (udp4) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io });
|
||||||
|
if (tcp6) |*s| try group.concurrent(io, tcp_server.TcpServer.serve, .{ s, io });
|
||||||
|
if (tcp4) |*s| try group.concurrent(io, tcp_server.TcpServer.serve, .{ s, io });
|
||||||
|
|
||||||
|
const gate: ?*disk_monitor.Monitor = &monitor;
|
||||||
|
try group.concurrent(io, logger_mod.Logger.runWriter, .{ &query_logger, io, &querylog_writer_db, gate });
|
||||||
|
try group.concurrent(io, retention_mod.Retention.run, .{ &retention, io, &querylog_retention_db });
|
||||||
|
try group.concurrent(io, disk_monitor.Monitor.run, .{ &monitor, io });
|
||||||
|
try group.concurrent(io, manager_mod.Manager.runScheduler, .{ &manager, io });
|
||||||
|
try group.concurrent(io, clients.Tracker.run, .{ &tracker, io, &tracker_db, gate });
|
||||||
|
try group.concurrent(io, runMaintenance, .{ &h, io });
|
||||||
|
|
||||||
|
logStartup(io, &manager, upstreams.active().len, .{
|
||||||
|
.udp6 = if (udp6) |*s| s.boundAddress() else null,
|
||||||
|
.udp4 = if (udp4) |*s| s.boundAddress() else null,
|
||||||
|
.tcp6 = if (tcp6) |*s| s.boundAddress() else null,
|
||||||
|
.tcp4 = if (tcp4) |*s| s.boundAddress() else null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A canceled wait is a shutdown request too: whoever canceled this task
|
||||||
|
// wants the process to stop, and the teardown below is how it stops.
|
||||||
|
shutdown.wait(io) catch {};
|
||||||
|
log.info("shutting down", .{});
|
||||||
|
|
||||||
|
// Before the group is canceled, so the writer sees a closed queue and
|
||||||
|
// drains what it holds rather than losing it to cancellation (ruling 22).
|
||||||
|
query_logger.shutdown(io);
|
||||||
|
|
||||||
|
return cli.exit_ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// background maintenance
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Sweeps the cache and the rate-limiter table. Both are guarded by mutexes the
|
||||||
|
/// handler owns, because the handler is what contends for them; the sweeps live
|
||||||
|
/// here because walking a whole table is not work a query should pay for.
|
||||||
|
///
|
||||||
|
/// The locks are taken cancelably: unlike `handle`, this loop has an error
|
||||||
|
/// union to carry `error.Canceled` out of, and a shutdown that arrives while
|
||||||
|
/// the query path holds a lock should not wait for it.
|
||||||
|
fn runMaintenance(h: *handler.Handler, io: std.Io) std.Io.Cancelable!void {
|
||||||
|
const interval: std.Io.Clock.Duration = .{
|
||||||
|
.raw = .fromSeconds(maintenance_interval_s),
|
||||||
|
.clock = .boot,
|
||||||
|
};
|
||||||
|
while (true) {
|
||||||
|
try interval.sleep(io);
|
||||||
|
|
||||||
|
if (h.cache) |cache| {
|
||||||
|
const now_s = std.Io.Clock.real.now(io).toSeconds();
|
||||||
|
try h.cache_mutex.lock(io);
|
||||||
|
_ = cache.sweep(now_s);
|
||||||
|
h.cache_mutex.unlock(io);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (h.limiter) |limiter| {
|
||||||
|
const now = std.Io.Clock.awake.now(io);
|
||||||
|
try h.limiter_mutex.lock(io);
|
||||||
|
_ = limiter.sweep(now);
|
||||||
|
h.limiter_mutex.unlock(io);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// upstreams
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The pool's entries and everything they point into.
|
||||||
|
///
|
||||||
|
/// `Pool.Entry.client` is a type-erased pointer into `doh` or `dot`, and each
|
||||||
|
/// client borrows a slice of `doh_buf`/`dot_buf`, so all five allocations live
|
||||||
|
/// exactly as long as the pool does. One entry is used by one task at a time
|
||||||
|
/// (`Entry.busy`), which is why the buffers are per client and not shared the
|
||||||
|
/// way `cli.probeUpstreams` shares them.
|
||||||
|
const Upstreams = struct {
|
||||||
|
entries: []pool_mod.Entry,
|
||||||
|
used: usize,
|
||||||
|
doh: []doh_client.DohClient,
|
||||||
|
dot: []dot_client.DotClient,
|
||||||
|
doh_buf: []u8,
|
||||||
|
dot_buf: []u8,
|
||||||
|
|
||||||
|
/// A disabled upstream is left out entirely; a malformed one warns and is
|
||||||
|
/// skipped, because one bad row in a table of four must not take DNS down.
|
||||||
|
/// No usable row at all is a configuration fault.
|
||||||
|
fn build(
|
||||||
|
gpa: Allocator,
|
||||||
|
servers: []const model.UpstreamServer,
|
||||||
|
http: *std.http.Client,
|
||||||
|
bundle: *Certificate.Bundle,
|
||||||
|
bundle_lock: *std.Io.RwLock,
|
||||||
|
) (Allocator.Error || ConfigError)!Upstreams {
|
||||||
|
var enabled: usize = 0;
|
||||||
|
for (servers) |server| {
|
||||||
|
if (server.enabled) enabled += 1;
|
||||||
|
}
|
||||||
|
if (enabled == 0) return error.NoUsableUpstreams;
|
||||||
|
|
||||||
|
const chunk = tls.Client.min_buffer_len;
|
||||||
|
|
||||||
|
var self: Upstreams = .{
|
||||||
|
.entries = try gpa.alloc(pool_mod.Entry, enabled),
|
||||||
|
.used = 0,
|
||||||
|
.doh = &.{},
|
||||||
|
.dot = &.{},
|
||||||
|
.doh_buf = &.{},
|
||||||
|
.dot_buf = &.{},
|
||||||
|
};
|
||||||
|
errdefer self.deinit(gpa);
|
||||||
|
|
||||||
|
self.doh = try gpa.alloc(doh_client.DohClient, enabled);
|
||||||
|
self.dot = try gpa.alloc(dot_client.DotClient, enabled);
|
||||||
|
self.doh_buf = try gpa.alloc(u8, enabled * (doh_request_buf_len + doh_transfer_buf_len));
|
||||||
|
self.dot_buf = try gpa.alloc(u8, enabled * 4 * chunk);
|
||||||
|
|
||||||
|
var doh_count: usize = 0;
|
||||||
|
var dot_count: usize = 0;
|
||||||
|
|
||||||
|
for (servers) |server| {
|
||||||
|
if (!server.enabled) continue;
|
||||||
|
|
||||||
|
const endpoint = transport.Endpoint.parse(server.url) catch {
|
||||||
|
log.warn("upstream '{s}' is not an https:// or tls:// endpoint; skipped", .{server.url});
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const client: transport.Client = switch (endpoint.scheme) {
|
||||||
|
.doh => doh: {
|
||||||
|
const base = doh_count * (doh_request_buf_len + doh_transfer_buf_len);
|
||||||
|
const slot = &self.doh[doh_count];
|
||||||
|
slot.* = doh_client.DohClient.init(
|
||||||
|
http,
|
||||||
|
endpoint,
|
||||||
|
self.doh_buf[base..][0..doh_request_buf_len],
|
||||||
|
self.doh_buf[base + doh_request_buf_len ..][0..doh_transfer_buf_len],
|
||||||
|
) catch {
|
||||||
|
log.warn("upstream '{s}' is not a usable DoH url; skipped", .{server.url});
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
doh_count += 1;
|
||||||
|
break :doh slot.client();
|
||||||
|
},
|
||||||
|
.dot => dot: {
|
||||||
|
const base = dot_count * 4 * chunk;
|
||||||
|
const slot = &self.dot[dot_count];
|
||||||
|
slot.* = dot_client.DotClient.init(endpoint, server.tls_name, gpa, bundle, bundle_lock, .{
|
||||||
|
.tls_read = self.dot_buf[base..][0..chunk],
|
||||||
|
.tls_write = self.dot_buf[base + chunk ..][0..chunk],
|
||||||
|
.stream_read = self.dot_buf[base + 2 * chunk ..][0..chunk],
|
||||||
|
.stream_write = self.dot_buf[base + 3 * chunk ..][0..chunk],
|
||||||
|
});
|
||||||
|
dot_count += 1;
|
||||||
|
break :dot slot.client();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
self.entries[self.used] = .{
|
||||||
|
.endpoint = endpoint,
|
||||||
|
.client = client,
|
||||||
|
.priority = server.priority,
|
||||||
|
.enabled = true,
|
||||||
|
.health = .init,
|
||||||
|
};
|
||||||
|
self.used += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.used == 0) return error.NoUsableUpstreams;
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The prefix `Pool.init` is given. The rest of `entries` is allocated but
|
||||||
|
/// never filled, which is what keeps `deinit` able to free the whole block.
|
||||||
|
fn active(self: *Upstreams) []pool_mod.Entry {
|
||||||
|
return self.entries[0..self.used];
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deinit(self: *Upstreams, gpa: Allocator) void {
|
||||||
|
gpa.free(self.dot_buf);
|
||||||
|
gpa.free(self.doh_buf);
|
||||||
|
gpa.free(self.dot);
|
||||||
|
gpa.free(self.doh);
|
||||||
|
gpa.free(self.entries);
|
||||||
|
self.* = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// listeners
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The addresses actually bound, for the startup line. A null is a family this
|
||||||
|
/// process does not listen on separately — either because the system has no
|
||||||
|
/// IPv6, or because the IPv6 socket is dual-stack and already serves IPv4.
|
||||||
|
const Listeners = struct {
|
||||||
|
udp6: ?net.IpAddress,
|
||||||
|
udp4: ?net.IpAddress,
|
||||||
|
tcp6: ?net.IpAddress,
|
||||||
|
tcp4: ?net.IpAddress,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn parseBind(r: cli.Runner, text: []const u8, port: u16, field: []const u8) !net.IpAddress {
|
||||||
|
return net.IpAddress.parse(text, port) catch {
|
||||||
|
r.err.print("{s}: '{s}' is not an IP address\n", .{ field, text }) catch {};
|
||||||
|
return error.BadBindAddress;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reportBind(r: cli.Runner, which: []const u8, addr: net.IpAddress, err: anyerror) anyerror {
|
||||||
|
r.err.print("cannot bind {s} {f}: {s}\n", .{ which, addr, @errorName(err) }) catch {};
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The kernel has no IPv6 at all. Refusing to start would be the wrong answer
|
||||||
|
/// for the default configuration, which asks for both families.
|
||||||
|
fn ipv6Unavailable(err: anyerror) bool {
|
||||||
|
return switch (err) {
|
||||||
|
error.AddressFamilyUnsupported,
|
||||||
|
error.ProtocolUnsupportedBySystem,
|
||||||
|
error.ProtocolUnsupportedByAddressFamily,
|
||||||
|
=> true,
|
||||||
|
else => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn isWildcard(addr: net.IpAddress) bool {
|
||||||
|
switch (addr) {
|
||||||
|
.ip4 => |a| {
|
||||||
|
for (a.bytes) |b| if (b != 0) return false;
|
||||||
|
},
|
||||||
|
.ip6 => |a| {
|
||||||
|
for (a.bytes) |b| if (b != 0) return false;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// startup line
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// One line, at info, naming what an operator needs to see in `journalctl`
|
||||||
|
/// right after a restart: where it listens, how many upstreams it has, and
|
||||||
|
/// whether filtering is live.
|
||||||
|
fn logStartup(io: std.Io, manager: *manager_mod.Manager, upstream_count: usize, bound: Listeners) void {
|
||||||
|
var buf: [256]u8 = undefined;
|
||||||
|
var w: Writer = .fixed(&buf);
|
||||||
|
appendBind(&w, "udp", bound.udp6);
|
||||||
|
appendBind(&w, "udp", bound.udp4);
|
||||||
|
appendBind(&w, "tcp", bound.tcp6);
|
||||||
|
appendBind(&w, "tcp", bound.tcp4);
|
||||||
|
|
||||||
|
// The generation is read under the snapshot handle's shared lock, which is
|
||||||
|
// the same lock the reload took to publish it.
|
||||||
|
if (manager.acquire(io)) |snapshot| {
|
||||||
|
defer snapshot.release(io);
|
||||||
|
log.info("nxdns {s} serving on{s}; {d} upstream(s); blocklist generation {d}", .{
|
||||||
|
version.string,
|
||||||
|
w.buffered(),
|
||||||
|
upstream_count,
|
||||||
|
manager.generation,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log.info("nxdns {s} serving on{s}; {d} upstream(s); unfiltered (no blocklist snapshot)", .{
|
||||||
|
version.string,
|
||||||
|
w.buffered(),
|
||||||
|
upstream_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Silent on overflow: a truncated startup line is not worth a failure path,
|
||||||
|
/// and 256 bytes hold four addresses.
|
||||||
|
fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void {
|
||||||
|
const value = addr orelse return;
|
||||||
|
w.print(" {s} {f}", .{ which, value }) catch {};
|
||||||
|
}
|
||||||
+59
-13
@@ -18,12 +18,14 @@ const Writer = std.Io.Writer;
|
|||||||
const Certificate = std.crypto.Certificate;
|
const Certificate = std.crypto.Certificate;
|
||||||
const tls = std.crypto.tls;
|
const tls = std.crypto.tls;
|
||||||
|
|
||||||
|
const app = @import("app.zig");
|
||||||
const config_export = @import("config/export.zig");
|
const config_export = @import("config/export.zig");
|
||||||
const import = @import("config/import.zig");
|
const import = @import("config/import.zig");
|
||||||
const model = @import("config/model.zig");
|
const model = @import("config/model.zig");
|
||||||
const validate = @import("config/validate.zig");
|
const validate = @import("config/validate.zig");
|
||||||
const db = @import("storage/db.zig");
|
const db = @import("storage/db.zig");
|
||||||
const migrations = @import("storage/migrations.zig");
|
const migrations = @import("storage/migrations.zig");
|
||||||
|
const querylog_schema = @import("storage/querylog_schema.zig");
|
||||||
const doh_client = @import("upstream/doh_client.zig");
|
const doh_client = @import("upstream/doh_client.zig");
|
||||||
const dot_client = @import("upstream/dot_client.zig");
|
const dot_client = @import("upstream/dot_client.zig");
|
||||||
const pool = @import("upstream/pool.zig");
|
const pool = @import("upstream/pool.zig");
|
||||||
@@ -204,7 +206,9 @@ pub const DataDir = struct {
|
|||||||
_ = try std.Io.Dir.cwd().createDirPathStatus(io, data_dir, .fromMode(0o700));
|
_ = try std.Io.Dir.cwd().createDirPathStatus(io, data_dir, .fromMode(0o700));
|
||||||
}
|
}
|
||||||
|
|
||||||
var dir = try std.Io.Dir.cwd().openDir(io, data_dir, .{});
|
// `.iterate`: the disk monitor sizes the databases by scanning this
|
||||||
|
// directory, and an fd opened without it cannot be read as a directory.
|
||||||
|
var dir = try std.Io.Dir.cwd().openDir(io, data_dir, .{ .iterate = true });
|
||||||
errdefer dir.close(io);
|
errdefer dir.close(io);
|
||||||
|
|
||||||
// SQLite opens by path, not by directory handle, so both paths are
|
// SQLite opens by path, not by directory handle, so both paths are
|
||||||
@@ -243,6 +247,54 @@ pub const DataDir = struct {
|
|||||||
try db.applyPragmas(&database, .{});
|
try db.applyPragmas(&database, .{});
|
||||||
return database;
|
return database;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The first connection to `querylog.db`: `querylog_schema.open` creates the
|
||||||
|
/// file when it is missing and recreates it when it is unusable, so this is
|
||||||
|
/// the call that establishes the schema. `reopenQuerylogDb` is for the
|
||||||
|
/// connections that follow.
|
||||||
|
///
|
||||||
|
/// The 0600 chmod cannot come first the way `openConfigDb` does it — the
|
||||||
|
/// file may not exist yet, and a recreate replaces it — so the main file and
|
||||||
|
/// both WAL sidecars are locked down afterwards instead. A query log holds
|
||||||
|
/// every domain every client asked for, which is as sensitive as anything in
|
||||||
|
/// `config.db`.
|
||||||
|
///
|
||||||
|
/// `querylog_schema.open` resolves the path through SQLite's VFS as well as
|
||||||
|
/// through the directory handle, so it is given `cwd` and the joined path
|
||||||
|
/// rather than `self.dir` and a name (see its doc comment).
|
||||||
|
pub fn openQuerylogDb(self: *const DataDir, io: std.Io) !db.Db {
|
||||||
|
const opened = try querylog_schema.open(io, std.Io.Dir.cwd(), self.querylog_db_path);
|
||||||
|
var database = opened.database;
|
||||||
|
errdefer database.close();
|
||||||
|
try self.restrictQuerylogPermissions(io);
|
||||||
|
return database;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An additional connection to a `querylog.db` that `openQuerylogDb` has
|
||||||
|
/// already established. Phase 7 needs two — the log writer and the retention
|
||||||
|
/// pass each own one (`retention.zig`'s contract).
|
||||||
|
pub fn reopenQuerylogDb(self: *const DataDir, io: std.Io) !db.Db {
|
||||||
|
_ = io;
|
||||||
|
var database = try db.Db.open(self.querylog_db_path, .{ .mode = .read_write_existing });
|
||||||
|
errdefer database.close();
|
||||||
|
try db.applyPragmas(&database, .{});
|
||||||
|
return database;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sidecar that does not exist yet is not a failure: `-wal` and `-shm`
|
||||||
|
/// appear when SQLite first writes, and the next call catches them.
|
||||||
|
fn restrictQuerylogPermissions(self: *const DataDir, io: std.Io) !void {
|
||||||
|
for ([_][]const u8{
|
||||||
|
querylog_db_name,
|
||||||
|
querylog_db_name ++ "-wal",
|
||||||
|
querylog_db_name ++ "-shm",
|
||||||
|
}) |entry| {
|
||||||
|
self.dir.setFilePermissions(io, entry, .fromMode(0o600), .{}) catch |e| switch (e) {
|
||||||
|
error.FileNotFound => {},
|
||||||
|
else => |other| return other,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -320,12 +372,11 @@ pub fn runVersion(r: Runner) u8 {
|
|||||||
return finish(r, exit_ok);
|
return finish(r, exit_ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unchanged from milestone 1. Wiring the configuration into the servers is
|
/// Serves DNS until SIGINT or SIGTERM. The whole of it lives in `app.zig`,
|
||||||
/// Phase 7; there is deliberately no half-built serving path here.
|
/// which is where the composition root belongs; this stays the entry point so
|
||||||
|
/// that `main` dispatches every command the same way.
|
||||||
pub fn runRun(r: Runner, paths: Paths) u8 {
|
pub fn runRun(r: Runner, paths: Paths) u8 {
|
||||||
_ = paths;
|
return app.run(r, paths);
|
||||||
r.out.writeAll("not implemented\n") catch return finish(r, exit_runtime);
|
|
||||||
return finish(r, exit_check);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn runExport(r: Runner, args: ExportArgs) u8 {
|
pub fn runExport(r: Runner, args: ExportArgs) u8 {
|
||||||
@@ -879,13 +930,8 @@ test "runVersion prints the milestone-1 version lines and exits 0" {
|
|||||||
try testing.expectEqual(@as(usize, 2), countLines(captured.out.written()));
|
try testing.expectEqual(@as(usize, 2), countLines(captured.out.written()));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "runRun still reports that serving is not implemented" {
|
// `runRun` now binds sockets and serves until a signal arrives, so it has no
|
||||||
var captured: Captured = .init(testing.allocator);
|
// unit test: booting it is `src/server/phase7_integration_test.zig`'s case 11.
|
||||||
defer captured.deinit();
|
|
||||||
|
|
||||||
try testing.expectEqual(exit_check, runRun(captured.runner(), .{}));
|
|
||||||
try testing.expectEqualStrings("not implemented\n", captured.out.written());
|
|
||||||
}
|
|
||||||
|
|
||||||
test "runUsageError names the fault and prints the usage text" {
|
test "runUsageError names the fault and prints the usage text" {
|
||||||
var captured: Captured = .init(testing.allocator);
|
var captured: Captured = .init(testing.allocator);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const types = @import("types.zig");
|
const types = @import("types.zig");
|
||||||
const record = @import("record.zig");
|
const record = @import("record.zig");
|
||||||
|
const question = @import("question.zig");
|
||||||
|
const packet_mod = @import("packet.zig");
|
||||||
const Writer = std.Io.Writer;
|
const Writer = std.Io.Writer;
|
||||||
|
|
||||||
/// The EDNS Client Subnet option code (RFC 7871 §6).
|
/// The EDNS Client Subnet option code (RFC 7871 §6).
|
||||||
@@ -159,6 +161,111 @@ fn ttlFrom(opt: OptRecord) u32 {
|
|||||||
(@as(u32, @intFromBool(opt.do_bit)) << 15);
|
(@as(u32, @intFromBool(opt.do_bit)) << 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const StripResult = union(enum) {
|
||||||
|
/// The query needs no rewrite and nothing was written to `out`.
|
||||||
|
unchanged,
|
||||||
|
/// The rewritten query, a prefix of `out`.
|
||||||
|
rewritten: []u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Byte offset of RDLENGTH inside an OPT record as `encodeOpt` writes it: a
|
||||||
|
/// one-byte root owner name, TYPE, CLASS and TTL come first.
|
||||||
|
const opt_rdlength_offset = 1 + 2 + 2 + 4;
|
||||||
|
|
||||||
|
/// Rebuilds `query` into `out` without its ECS option, so that a client's
|
||||||
|
/// subnet never reaches the upstream resolver (PLAN §6.1). Every byte outside
|
||||||
|
/// the OPT record is copied verbatim — ID, flags, all four section counts, the
|
||||||
|
/// question and any record beside the OPT — and the OPT keeps its payload size,
|
||||||
|
/// its flags and every option other than code 8.
|
||||||
|
///
|
||||||
|
/// `pkt` and `opt` are the caller's already-parsed views of `query`, and `out`
|
||||||
|
/// must not overlap `query`.
|
||||||
|
///
|
||||||
|
/// One byte pattern does not survive the rewrite: a compression pointer in a
|
||||||
|
/// record that follows the OPT and targets a byte inside the OPT's option list.
|
||||||
|
/// Removing an option shifts those bytes, so the pointer then decodes to
|
||||||
|
/// something else. RFC 1035 §4.1.4 only ever points a name at an earlier *name*,
|
||||||
|
/// so such a pointer is malformed to begin with, and the upstream resolver
|
||||||
|
/// answers the rewritten query with FORMERR instead of the original's answer.
|
||||||
|
pub fn stripEcs(
|
||||||
|
query: []const u8,
|
||||||
|
pkt: packet_mod.Packet,
|
||||||
|
opt: OptRecord,
|
||||||
|
out: []u8,
|
||||||
|
) error{ BadOption, Overflow }!StripResult {
|
||||||
|
std.debug.assert(query.ptr == pkt.bytes.ptr);
|
||||||
|
std.debug.assert(query.len == pkt.bytes.len);
|
||||||
|
|
||||||
|
// The rebuild reads `query` while it writes `out`, so an overlap would let
|
||||||
|
// it consume bytes it has already overwritten. The two buffers must be
|
||||||
|
// disjoint, and the caller finds that out here rather than in the packet it
|
||||||
|
// sends upstream.
|
||||||
|
std.debug.assert(@intFromPtr(out.ptr) + out.len <= @intFromPtr(query.ptr) or
|
||||||
|
@intFromPtr(query.ptr) + query.len <= @intFromPtr(out.ptr));
|
||||||
|
|
||||||
|
const opt_start = (try optRecordStart(pkt, opt)) orelse return .unchanged;
|
||||||
|
if ((try findOption(query, opt, ecs_option_code)) == null) return .unchanged;
|
||||||
|
|
||||||
|
var w = Writer.fixed(out);
|
||||||
|
w.writeAll(query[0..opt_start]) catch return error.Overflow;
|
||||||
|
encodeOpt(opt, &.{}, &w) catch |err| switch (err) {
|
||||||
|
error.OptionsTooLong => unreachable, // the list written here is empty
|
||||||
|
error.WriteFailed => return error.Overflow,
|
||||||
|
};
|
||||||
|
|
||||||
|
// RDLENGTH is only known once the list has been filtered, and the surviving
|
||||||
|
// options are not contiguous in `query`, so `encodeOpt` writes a placeholder
|
||||||
|
// and the options stream in behind it.
|
||||||
|
var kept_len: usize = 0;
|
||||||
|
var it = options(query, opt);
|
||||||
|
while (try it.next()) |o| {
|
||||||
|
if (o.code == ecs_option_code) continue;
|
||||||
|
var option_header: [4]u8 = undefined;
|
||||||
|
std.mem.writeInt(u16, option_header[0..2], o.code, .big);
|
||||||
|
std.mem.writeInt(u16, option_header[2..4], @intCast(o.data.len), .big);
|
||||||
|
w.writeAll(&option_header) catch return error.Overflow;
|
||||||
|
w.writeAll(o.data) catch return error.Overflow;
|
||||||
|
kept_len += option_header.len + o.data.len;
|
||||||
|
}
|
||||||
|
|
||||||
|
w.writeAll(query[opt.options.offset + opt.options.len ..]) catch return error.Overflow;
|
||||||
|
|
||||||
|
const message = w.buffered();
|
||||||
|
std.mem.writeInt(u16, message[opt_start + opt_rdlength_offset ..][0..2], @intCast(kept_len), .big);
|
||||||
|
return .{ .rewritten = message };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the record holding `opt` begins, or null when `pkt` has no such
|
||||||
|
/// record. A record's start offset is not part of a parsed view — only a walk
|
||||||
|
/// of every section before it reaches that byte — so this repeats the walk
|
||||||
|
/// `packet.parse` already did.
|
||||||
|
///
|
||||||
|
/// The match is on the whole RDATA span, which keeps a hand-built `OptRecord`
|
||||||
|
/// from pointing this function at a different record than the one it describes.
|
||||||
|
/// A section that will not walk lands on `error.BadOption` for the same reason:
|
||||||
|
/// a `Packet` from `packet.parse` always walks, so only a `Packet` assembled by
|
||||||
|
/// hand around unvalidated bytes gets here.
|
||||||
|
fn optRecordStart(pkt: packet_mod.Packet, opt: OptRecord) error{BadOption}!?usize {
|
||||||
|
var pos: usize = types.header_len;
|
||||||
|
var q: u16 = 0;
|
||||||
|
while (q < pkt.header.qdcount) : (q += 1) {
|
||||||
|
const parsed = question.parse(pkt.bytes, pos) catch return error.BadOption;
|
||||||
|
pos = parsed.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record_count = @as(u32, pkt.header.ancount) +
|
||||||
|
@as(u32, pkt.header.nscount) + @as(u32, pkt.header.arcount);
|
||||||
|
var r: u32 = 0;
|
||||||
|
while (r < record_count) : (r += 1) {
|
||||||
|
const parsed = record.parse(pkt.bytes, pos) catch return error.BadOption;
|
||||||
|
if (parsed.record.rtype == .opt and
|
||||||
|
parsed.record.rdata.offset == opt.options.offset and
|
||||||
|
parsed.record.rdata.len == opt.options.len) return pos;
|
||||||
|
pos = parsed.end;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// The full 12-bit RCODE (RFC 6891 §6.1.3): the OPT record supplies the upper
|
/// The full 12-bit RCODE (RFC 6891 §6.1.3): the OPT record supplies the upper
|
||||||
/// eight bits, the header the lower four. Without an OPT record the value is
|
/// eight bits, the header the lower four. Without an OPT record the value is
|
||||||
/// just the header's four bits.
|
/// just the header's four bits.
|
||||||
@@ -406,6 +513,172 @@ test "encodeOpt reports a short buffer" {
|
|||||||
try testing.expectError(error.WriteFailed, encodeOpt(opt, "", &w));
|
try testing.expectError(error.WriteFailed, encodeOpt(opt, "", &w));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A query for example.com A whose OPT record carries an ECS option for
|
||||||
|
/// 192.0.2.0/24 followed by a two-byte padding option, with DO set and a
|
||||||
|
/// 4096-byte payload size.
|
||||||
|
const ecs_query =
|
||||||
|
"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++ // header
|
||||||
|
"\x07example\x03com\x00\x00\x01\x00\x01" ++ // question
|
||||||
|
"\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x11" ++ // OPT, 17 rdata bytes
|
||||||
|
"\x00\x08\x00\x07\x00\x01\x18\x00\xc0\x00\x02" ++ // ECS
|
||||||
|
"\x00\x0c\x00\x02\x00\x00"; // padding
|
||||||
|
|
||||||
|
/// `ecs_query` as `stripEcs` must rebuild it.
|
||||||
|
const ecs_query_stripped =
|
||||||
|
"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++
|
||||||
|
"\x07example\x03com\x00\x00\x01\x00\x01" ++
|
||||||
|
"\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x06" ++
|
||||||
|
"\x00\x0c\x00\x02\x00\x00";
|
||||||
|
|
||||||
|
const ParsedQuery = struct { pkt: packet_mod.Packet, opt: OptRecord };
|
||||||
|
|
||||||
|
fn parseQuery(bytes: []const u8) !ParsedQuery {
|
||||||
|
const p = try packet_mod.parse(bytes);
|
||||||
|
return .{ .pkt = p, .opt = try parseOpt(bytes, packet_mod.findOptRecord(p).?) };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expectUnchanged(result: StripResult) !void {
|
||||||
|
switch (result) {
|
||||||
|
.unchanged => {},
|
||||||
|
.rewritten => return error.TestExpectedUnchanged,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "stripEcs rebuilds the query without the ECS option" {
|
||||||
|
const parsed = try parseQuery(ecs_query);
|
||||||
|
var out: [512]u8 = undefined;
|
||||||
|
const result = try stripEcs(ecs_query, parsed.pkt, parsed.opt, &out);
|
||||||
|
const rewritten = result.rewritten;
|
||||||
|
try testing.expectEqualSlices(u8, ecs_query_stripped, rewritten);
|
||||||
|
|
||||||
|
const p = try packet_mod.parse(rewritten);
|
||||||
|
try testing.expectEqual(@as(u16, 0x1234), p.header.id);
|
||||||
|
try testing.expectEqual(@as(u16, 1), p.header.qdcount);
|
||||||
|
try testing.expectEqual(@as(u16, 1), p.header.arcount);
|
||||||
|
|
||||||
|
const opt = try parseOpt(rewritten, packet_mod.findOptRecord(p).?);
|
||||||
|
try testing.expectEqual(@as(u16, 4096), opt.udp_payload_size);
|
||||||
|
try testing.expectEqual(true, opt.do_bit);
|
||||||
|
try testing.expectEqual(@as(?Option, null), try findOption(rewritten, opt, ecs_option_code));
|
||||||
|
try testing.expectEqualSlices(u8, "\x00\x00", (try findOption(rewritten, opt, 12)).?.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "stripEcs reports a query with nothing to strip as unchanged" {
|
||||||
|
const parsed = try parseQuery(ecs_query_stripped);
|
||||||
|
var out: [512]u8 = undefined;
|
||||||
|
try expectUnchanged(try stripEcs(ecs_query_stripped, parsed.pkt, parsed.opt, &out));
|
||||||
|
|
||||||
|
// No OPT record at all: the caller still holds an `OptRecord`, so the
|
||||||
|
// absence has to be found by the walk rather than assumed.
|
||||||
|
const no_opt = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
|
||||||
|
"\x07example\x03com\x00\x00\x01\x00\x01";
|
||||||
|
const p = try packet_mod.parse(no_opt);
|
||||||
|
try testing.expectEqual(@as(?record.Record, null), packet_mod.findOptRecord(p));
|
||||||
|
const absent: OptRecord = .{
|
||||||
|
.udp_payload_size = 4096,
|
||||||
|
.extended_rcode = 0,
|
||||||
|
.version = 0,
|
||||||
|
.do_bit = false,
|
||||||
|
.options = .{ .offset = 0, .len = 0 },
|
||||||
|
};
|
||||||
|
try expectUnchanged(try stripEcs(no_opt, p, absent, &out));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "stripEcs keeps a record that follows the OPT" {
|
||||||
|
const head = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x02" ++
|
||||||
|
"\x07example\x03com\x00\x00\x01\x00\x01";
|
||||||
|
const trailing = "\x00\x00\x01\x00\x01\x00\x00\x00\x0a\x00\x04\x01\x02\x03\x04";
|
||||||
|
const query = head ++
|
||||||
|
"\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x0b" ++
|
||||||
|
"\x00\x08\x00\x07\x00\x01\x18\x00\xc0\x00\x02" ++
|
||||||
|
trailing;
|
||||||
|
|
||||||
|
const parsed = try parseQuery(query);
|
||||||
|
var out: [512]u8 = undefined;
|
||||||
|
const rewritten = (try stripEcs(query, parsed.pkt, parsed.opt, &out)).rewritten;
|
||||||
|
try testing.expectEqualSlices(
|
||||||
|
u8,
|
||||||
|
head ++ "\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x00" ++ trailing,
|
||||||
|
rewritten,
|
||||||
|
);
|
||||||
|
try testing.expectEqual(@as(u16, 2), (try packet_mod.parse(rewritten)).header.arcount);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "stripEcs removes a repeated ECS option" {
|
||||||
|
const ecs = "\x00\x08\x00\x07\x00\x01\x18\x00\xc0\x00\x02";
|
||||||
|
const query = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++
|
||||||
|
"\x07example\x03com\x00\x00\x01\x00\x01" ++
|
||||||
|
"\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x1c" ++
|
||||||
|
ecs ++ "\x00\x0c\x00\x02\x00\x00" ++ ecs;
|
||||||
|
|
||||||
|
const parsed = try parseQuery(query);
|
||||||
|
var out: [512]u8 = undefined;
|
||||||
|
const rewritten = (try stripEcs(query, parsed.pkt, parsed.opt, &out)).rewritten;
|
||||||
|
try testing.expectEqualSlices(u8, ecs_query_stripped, rewritten);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "stripEcs reports an output buffer one byte short" {
|
||||||
|
const parsed = try parseQuery(ecs_query);
|
||||||
|
|
||||||
|
var exact: [ecs_query_stripped.len]u8 = undefined;
|
||||||
|
_ = (try stripEcs(ecs_query, parsed.pkt, parsed.opt, &exact)).rewritten;
|
||||||
|
|
||||||
|
var short: [ecs_query_stripped.len - 1]u8 = undefined;
|
||||||
|
try testing.expectError(error.Overflow, stripEcs(ecs_query, parsed.pkt, parsed.opt, &short));
|
||||||
|
|
||||||
|
// Every shorter buffer fails the same way, including one that cannot even
|
||||||
|
// hold the copied header.
|
||||||
|
var i: usize = 0;
|
||||||
|
while (i < short.len) : (i += 1) {
|
||||||
|
try testing.expectError(
|
||||||
|
error.Overflow,
|
||||||
|
stripEcs(ecs_query, parsed.pkt, parsed.opt, short[0..i]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "stripEcs rejects a malformed option list" {
|
||||||
|
// The record is well-formed; only its option list overruns, so `parseOpt`
|
||||||
|
// is the one thing that rejects this packet.
|
||||||
|
const query = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++
|
||||||
|
"\x07example\x03com\x00\x00\x01\x00\x01" ++
|
||||||
|
"\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x08" ++
|
||||||
|
"\x00\x08\x00\x08\x00\x01\x18\x00";
|
||||||
|
|
||||||
|
const p = try packet_mod.parse(query);
|
||||||
|
const rec = packet_mod.findOptRecord(p).?;
|
||||||
|
try testing.expectError(error.BadOption, parseOpt(query, rec));
|
||||||
|
|
||||||
|
const hand_built: OptRecord = .{
|
||||||
|
.udp_payload_size = rec.class,
|
||||||
|
.extended_rcode = 0,
|
||||||
|
.version = 0,
|
||||||
|
.do_bit = false,
|
||||||
|
.options = rec.rdata,
|
||||||
|
};
|
||||||
|
|
||||||
|
var out: [512]u8 = undefined;
|
||||||
|
try testing.expectError(error.BadOption, stripEcs(query, p, hand_built, &out));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "encodeOpt writes RDLENGTH where stripEcs patches it" {
|
||||||
|
const opt: OptRecord = .{
|
||||||
|
.udp_payload_size = 4096,
|
||||||
|
.extended_rcode = 0,
|
||||||
|
.version = 0,
|
||||||
|
.do_bit = true,
|
||||||
|
.options = .{ .offset = 0, .len = 0 },
|
||||||
|
};
|
||||||
|
var buf: [32]u8 = undefined;
|
||||||
|
var w = Writer.fixed(&buf);
|
||||||
|
try encodeOpt(opt, "\x00\x0c\x00\x02\x00\x00", &w);
|
||||||
|
const bytes = w.buffered();
|
||||||
|
try testing.expectEqual(
|
||||||
|
@as(u16, 6),
|
||||||
|
std.mem.readInt(u16, bytes[opt_rdlength_offset..][0..2], .big),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test "extendedRcode composes the twelve bits" {
|
test "extendedRcode composes the twelve bits" {
|
||||||
try testing.expectEqual(@as(u12, 3), extendedRcode(.nx_domain, null));
|
try testing.expectEqual(@as(u12, 3), extendedRcode(.nx_domain, null));
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const clients_repo = @import("../storage/repositories/clients_repo.zig");
|
|||||||
const groups_repo = @import("../storage/repositories/groups_repo.zig");
|
const groups_repo = @import("../storage/repositories/groups_repo.zig");
|
||||||
const rules_repo = @import("../storage/repositories/rules_repo.zig");
|
const rules_repo = @import("../storage/repositories/rules_repo.zig");
|
||||||
const sources_repo = @import("../storage/repositories/sources_repo.zig");
|
const sources_repo = @import("../storage/repositories/sources_repo.zig");
|
||||||
|
const disk_monitor = @import("../storage/disk_monitor.zig");
|
||||||
const compiler = @import("compiler.zig");
|
const compiler = @import("compiler.zig");
|
||||||
const fetcher = @import("fetcher.zig");
|
const fetcher = @import("fetcher.zig");
|
||||||
const matcher = @import("matcher.zig");
|
const matcher = @import("matcher.zig");
|
||||||
@@ -221,6 +222,15 @@ pub const Manager = struct {
|
|||||||
/// Owns the `statuses` table. The entries themselves borrow nothing.
|
/// Owns the `statuses` table. The entries themselves borrow nothing.
|
||||||
status_arena: std.heap.ArenaAllocator,
|
status_arena: std.heap.ArenaAllocator,
|
||||||
|
|
||||||
|
/// The §11.6 disk gate (ruling 17). Set by the composition root after
|
||||||
|
/// `init` and before `runScheduler` starts; null disables gating, which is
|
||||||
|
/// what every test and `nxdns check` want. Only the scheduler consults it —
|
||||||
|
/// see `refreshGated`.
|
||||||
|
monitor: ?*disk_monitor.Monitor = null,
|
||||||
|
/// Scheduled refresh passes skipped by the disk gate. Phase 8's health
|
||||||
|
/// rollup reads it through `refreshesGated`.
|
||||||
|
refreshes_gated: std.atomic.Value(u64) = .init(0),
|
||||||
|
|
||||||
pub const Error = error{
|
pub const Error = error{
|
||||||
OutOfMemory,
|
OutOfMemory,
|
||||||
Canceled,
|
Canceled,
|
||||||
@@ -958,6 +968,7 @@ pub const Manager = struct {
|
|||||||
};
|
};
|
||||||
while (true) {
|
while (true) {
|
||||||
try interval.sleep(io);
|
try interval.sleep(io);
|
||||||
|
if (self.refreshGated()) continue;
|
||||||
self.refreshAll(io) catch |err| switch (err) {
|
self.refreshAll(io) catch |err| switch (err) {
|
||||||
error.Canceled => return error.Canceled,
|
error.Canceled => return error.Canceled,
|
||||||
else => log.warn("blocklist refresh pass failed: {s}", .{@errorName(err)}),
|
else => log.warn("blocklist refresh pass failed: {s}", .{@errorName(err)}),
|
||||||
@@ -965,12 +976,42 @@ pub const Manager = struct {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The §11.6 gate, consulted by scheduled passes only (ruling 17). A
|
||||||
|
/// download writes tens of megabytes into the blocklist directory and the
|
||||||
|
/// compile writes as much again, which is exactly the "non-essential write"
|
||||||
|
/// a critically full disk must not take.
|
||||||
|
///
|
||||||
|
/// `reload` and `refreshAll` are deliberately not gated: both are operator
|
||||||
|
/// actions (the composition root's startup load, Phase 8's manual refresh),
|
||||||
|
/// and an operator who asks for a refresh on a full disk has asked for it.
|
||||||
|
///
|
||||||
|
/// Counting happens here, so a caller cannot skip a pass without recording
|
||||||
|
/// it. One `warn` line per skipped pass — at a 24-hour interval that is one
|
||||||
|
/// line a day, and the disk monitor already logs the state change itself.
|
||||||
|
fn refreshGated(self: *Manager) bool {
|
||||||
|
const monitor = self.monitor orelse return false;
|
||||||
|
if (monitor.writesAllowed()) return false;
|
||||||
|
_ = self.refreshes_gated.fetchAdd(1, .monotonic);
|
||||||
|
log.warn("free space is critical; skipping the scheduled blocklist refresh", .{});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scheduled refresh passes the disk gate has skipped.
|
||||||
|
pub fn refreshesGated(self: *const Manager) u64 {
|
||||||
|
return self.refreshes_gated.load(.monotonic);
|
||||||
|
}
|
||||||
|
|
||||||
fn startupPass(self: *Manager, io: std.Io) Error!void {
|
fn startupPass(self: *Manager, io: std.Io) Error!void {
|
||||||
self.writer_lock.lockUncancelable(io);
|
self.writer_lock.lockUncancelable(io);
|
||||||
defer self.writer_lock.unlock(io);
|
defer self.writer_lock.unlock(io);
|
||||||
|
|
||||||
|
// Ahead of the gate on purpose: loading the compiled files that already
|
||||||
|
// exist is a read. A full disk must not cost the household its
|
||||||
|
// filtering as well as its downloads.
|
||||||
try self.reloadLocked(io);
|
try self.reloadLocked(io);
|
||||||
|
|
||||||
|
if (self.refreshGated()) return;
|
||||||
|
|
||||||
var rows = try sources_repo.listSourceRows(self.database, self.gpa);
|
var rows = try sources_repo.listSourceRows(self.database, self.gpa);
|
||||||
defer rows.deinit(self.gpa);
|
defer rows.deinit(self.gpa);
|
||||||
defer sources_repo.freeSourceRows(self.gpa, rows.items);
|
defer sources_repo.freeSourceRows(self.gpa, rows.items);
|
||||||
@@ -1439,6 +1480,42 @@ test "acquire before any reload returns null and holds no lock" {
|
|||||||
manager.lock.unlock(io);
|
manager.lock.unlock(io);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "the disk gate skips a scheduled refresh only while writes are critical" {
|
||||||
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var database = try openMigrated();
|
||||||
|
defer database.close();
|
||||||
|
|
||||||
|
var f: fetcher.Fetcher = undefined;
|
||||||
|
var manager = try testManager(&database, &f);
|
||||||
|
defer manager.deinit(io);
|
||||||
|
|
||||||
|
// No monitor: every pass runs, which is what the tests and `check` rely on.
|
||||||
|
try testing.expect(!manager.refreshGated());
|
||||||
|
try testing.expectEqual(@as(u64, 0), manager.refreshesGated());
|
||||||
|
|
||||||
|
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
|
||||||
|
manager.monitor = &monitor;
|
||||||
|
|
||||||
|
// `.ok` and `.warn` both allow writes: only `critical` stops them.
|
||||||
|
try testing.expect(!manager.refreshGated());
|
||||||
|
monitor.state_raw.store(@intFromEnum(disk_monitor.State.warn), .monotonic);
|
||||||
|
try testing.expect(!manager.refreshGated());
|
||||||
|
try testing.expectEqual(@as(u64, 0), manager.refreshesGated());
|
||||||
|
|
||||||
|
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
|
||||||
|
try testing.expect(manager.refreshGated());
|
||||||
|
try testing.expect(manager.refreshGated());
|
||||||
|
try testing.expectEqual(@as(u64, 2), manager.refreshesGated());
|
||||||
|
|
||||||
|
// Free space recovers and the schedule resumes; the counter keeps its total.
|
||||||
|
monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic);
|
||||||
|
try testing.expect(!manager.refreshGated());
|
||||||
|
try testing.expectEqual(@as(u64, 2), manager.refreshesGated());
|
||||||
|
}
|
||||||
|
|
||||||
test "statusSnapshot on an empty manager copies nothing" {
|
test "statusSnapshot on an empty manager copies nothing" {
|
||||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||||
defer threaded.deinit();
|
defer threaded.deinit();
|
||||||
|
|||||||
@@ -0,0 +1,502 @@
|
|||||||
|
//! Client auto-materialisation (PLAN §7.2): every address that asks a question
|
||||||
|
//! ends up as a row in `clients`, so the operator can name it and assign it a
|
||||||
|
//! group without typing an address by hand.
|
||||||
|
//!
|
||||||
|
//! A DNS query must never wait on a database write, so `track` only records the
|
||||||
|
//! address in a fixed-capacity table in memory. A background loop drains that
|
||||||
|
//! table every `flush_interval_s` and writes one row per distinct client, and
|
||||||
|
//! prunes the rows of devices that went quiet on every
|
||||||
|
//! `prune_every_passes`-th pass.
|
||||||
|
//!
|
||||||
|
//! The table is bounded at `max_pending`. A full table drops the address and
|
||||||
|
//! counts it under `dropped_full`: a burst of spoofed source addresses must not
|
||||||
|
//! be able to grow this allocation, and a dropped address costs nothing, since
|
||||||
|
//! the next query from that client tracks it again.
|
||||||
|
//!
|
||||||
|
//! A materialised row does not reach the matcher until the next manager reload.
|
||||||
|
//! Nothing depends on it: `groupForClient` already falls back to the prefix
|
||||||
|
//! rules and then to the default group, so the row exists for the operator's
|
||||||
|
//! benefit, not for resolution.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
const address = @import("../platform/address.zig");
|
||||||
|
const clients_repo = @import("../storage/repositories/clients_repo.zig");
|
||||||
|
const db = @import("../storage/db.zig");
|
||||||
|
const disk_monitor = @import("../storage/disk_monitor.zig");
|
||||||
|
|
||||||
|
const log = std.log.scoped(.clients);
|
||||||
|
|
||||||
|
/// RFC 5952 text of any IPv6 address. `format` never writes more than this, so
|
||||||
|
/// the formatting in `flushOnce` cannot fail.
|
||||||
|
const max_ip_text = 45;
|
||||||
|
|
||||||
|
/// One address waiting for its row, with the wall-clock second of its most
|
||||||
|
/// recent query.
|
||||||
|
const Pending = struct {
|
||||||
|
addr: address.NetAddress,
|
||||||
|
last_seen: i64,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const Tracker = struct {
|
||||||
|
/// Distinct clients one flush interval can carry. A household LAN holds two
|
||||||
|
/// orders of magnitude fewer; the headroom is for the spoofing case.
|
||||||
|
pub const max_pending = 512;
|
||||||
|
pub const flush_interval_s = 60;
|
||||||
|
/// One day at `flush_interval_s` seconds per pass.
|
||||||
|
pub const prune_every_passes = 1440;
|
||||||
|
|
||||||
|
/// `tracked` counts the `track` calls that landed in the table, whether
|
||||||
|
/// they created an entry or refreshed one, so `tracked + dropped_full` is
|
||||||
|
/// the number of `track` calls.
|
||||||
|
pub const Stats = struct {
|
||||||
|
tracked: u64 = 0,
|
||||||
|
flushed: u64 = 0,
|
||||||
|
dropped_full: u64 = 0,
|
||||||
|
pruned: u64 = 0,
|
||||||
|
flush_failures: u64 = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Guards `pending`, `count`, `passes` and `stats`. Every field below is
|
||||||
|
/// written under it, so a reader takes it too; see `snapshotStats`.
|
||||||
|
mutex: std.Io.Mutex,
|
||||||
|
retention_days: u16,
|
||||||
|
pending: [max_pending]Pending,
|
||||||
|
count: u32,
|
||||||
|
passes: u64,
|
||||||
|
stats: Stats,
|
||||||
|
|
||||||
|
/// `retention_days` is `logging.retention_days`, the same knob the query log
|
||||||
|
/// prunes by (milestone-7 ruling 16). A client silent for that long is as
|
||||||
|
/// uninteresting as a query that old.
|
||||||
|
pub fn init(retention_days: u16) Tracker {
|
||||||
|
return .{
|
||||||
|
.mutex = .init,
|
||||||
|
.retention_days = retention_days,
|
||||||
|
.pending = undefined,
|
||||||
|
.count = 0,
|
||||||
|
.passes = 0,
|
||||||
|
.stats = .{},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records `addr` as seen now. Called from the query path, so it writes no
|
||||||
|
/// database and returns no error: a full table drops the address.
|
||||||
|
///
|
||||||
|
/// `lockUncancelable` rather than `lock`: the caller is `Handler.handle`,
|
||||||
|
/// which has no error union to carry `error.Canceled` out of. The critical
|
||||||
|
/// section is a scan of at most `max_pending` addresses and holds no I/O.
|
||||||
|
pub fn track(self: *Tracker, io: std.Io, addr: address.NetAddress) void {
|
||||||
|
self.trackAt(io, addr, std.Io.Clock.real.now(io).toSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `track` with the timestamp supplied, so a test does not depend on the
|
||||||
|
/// wall clock.
|
||||||
|
pub fn trackAt(self: *Tracker, io: std.Io, addr: address.NetAddress, now_s: i64) void {
|
||||||
|
self.mutex.lockUncancelable(io);
|
||||||
|
defer self.mutex.unlock(io);
|
||||||
|
|
||||||
|
for (self.pending[0..self.count]) |*entry| {
|
||||||
|
if (!entry.addr.eql(addr)) continue;
|
||||||
|
entry.last_seen = now_s;
|
||||||
|
self.stats.tracked += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (self.count == max_pending) {
|
||||||
|
self.stats.dropped_full += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.pending[self.count] = .{ .addr = addr, .last_seen = now_s };
|
||||||
|
self.count += 1;
|
||||||
|
self.stats.tracked += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush loop, first flush one interval in: an empty table at startup has
|
||||||
|
/// nothing to write.
|
||||||
|
///
|
||||||
|
/// `boot` rather than `awake`, so a box that suspends still sees its day
|
||||||
|
/// elapse and prunes on schedule.
|
||||||
|
///
|
||||||
|
/// `database` must be a connection dedicated to this loop: no other task may
|
||||||
|
/// use the same handle while it runs. `FULLMUTEX` (`db.zig:218`) serializes
|
||||||
|
/// one SQLite call against another, but a transaction is connection state,
|
||||||
|
/// not call state, so a flush that lands between another writer's BEGIN and
|
||||||
|
/// COMMIT would commit or roll back with that writer's batch. Milestone-7
|
||||||
|
/// ruling 21 gives this loop its own `config.db` connection; the tracker
|
||||||
|
/// opens nothing itself.
|
||||||
|
///
|
||||||
|
/// `monitor` gates the pass: while free space is critical the tracker writes
|
||||||
|
/// nothing, and the addresses it would have written stay dropped.
|
||||||
|
pub fn run(
|
||||||
|
self: *Tracker,
|
||||||
|
io: std.Io,
|
||||||
|
database: *db.Db,
|
||||||
|
monitor: ?*disk_monitor.Monitor,
|
||||||
|
) std.Io.Cancelable!void {
|
||||||
|
const interval: std.Io.Clock.Duration = .{
|
||||||
|
.raw = .fromSeconds(flush_interval_s),
|
||||||
|
.clock = .boot,
|
||||||
|
};
|
||||||
|
while (true) {
|
||||||
|
try interval.sleep(io);
|
||||||
|
const writes_allowed = if (monitor) |m| m.writesAllowed() else true;
|
||||||
|
self.flushOnce(io, database, writes_allowed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pass: drain the table, write a row per client, and prune on every
|
||||||
|
/// `prune_every_passes`-th pass.
|
||||||
|
///
|
||||||
|
/// A gated pass does nothing at all, not even count: the work it skipped is
|
||||||
|
/// still owed, and the pending addresses it leaves behind are re-tracked by
|
||||||
|
/// the next query from each client.
|
||||||
|
///
|
||||||
|
/// Every database failure logs one line at `warn` and counts. Nothing
|
||||||
|
/// retries within a pass: the dropped addresses come back on their own, and
|
||||||
|
/// a failed prune repeats the same work a day later against the same rows.
|
||||||
|
///
|
||||||
|
/// Only `run` may call this concurrently with itself — the drain buffer is
|
||||||
|
/// this call's stack, but the pass counter and the prune schedule assume a
|
||||||
|
/// single caller.
|
||||||
|
pub fn flushOnce(self: *Tracker, io: std.Io, database: *db.Db, writes_allowed: bool) void {
|
||||||
|
if (!writes_allowed) return;
|
||||||
|
|
||||||
|
var drained: [max_pending]Pending = undefined;
|
||||||
|
const batch = self.drain(io, &drained);
|
||||||
|
|
||||||
|
var flushed: u64 = 0;
|
||||||
|
var failures: u64 = 0;
|
||||||
|
for (batch) |entry| {
|
||||||
|
var buf: [max_ip_text]u8 = undefined;
|
||||||
|
var w: std.Io.Writer = .fixed(&buf);
|
||||||
|
entry.addr.format(&w) catch unreachable;
|
||||||
|
|
||||||
|
if (clients_repo.upsertSeen(database, w.buffered(), entry.last_seen)) {
|
||||||
|
flushed += 1;
|
||||||
|
} else |err| {
|
||||||
|
if (failures == 0) {
|
||||||
|
log.warn("materialising client {s} failed: {s}", .{ w.buffered(), @errorName(err) });
|
||||||
|
}
|
||||||
|
failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.mutex.lockUncancelable(io);
|
||||||
|
self.passes += 1;
|
||||||
|
self.stats.flushed += flushed;
|
||||||
|
self.stats.flush_failures += failures;
|
||||||
|
const due = self.passes % prune_every_passes == 0;
|
||||||
|
self.mutex.unlock(io);
|
||||||
|
|
||||||
|
if (!due) return;
|
||||||
|
const cutoff = std.Io.Clock.real.now(io).toSeconds() - @as(i64, self.retention_days) * 86_400;
|
||||||
|
if (clients_repo.pruneStale(database, cutoff)) |deleted| {
|
||||||
|
self.mutex.lockUncancelable(io);
|
||||||
|
self.stats.pruned += deleted;
|
||||||
|
self.mutex.unlock(io);
|
||||||
|
} else |err| {
|
||||||
|
log.warn("pruning clients before {d} failed: {s}", .{ cutoff, @errorName(err) });
|
||||||
|
self.mutex.lockUncancelable(io);
|
||||||
|
self.stats.flush_failures += 1;
|
||||||
|
self.mutex.unlock(io);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshotStats(self: *Tracker, io: std.Io) Stats {
|
||||||
|
self.mutex.lockUncancelable(io);
|
||||||
|
defer self.mutex.unlock(io);
|
||||||
|
return self.stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clients waiting for their row. Reaching `max_pending` is what turns
|
||||||
|
/// further addresses into `dropped_full`.
|
||||||
|
pub fn pendingClients(self: *Tracker, io: std.Io) u32 {
|
||||||
|
self.mutex.lockUncancelable(io);
|
||||||
|
defer self.mutex.unlock(io);
|
||||||
|
return self.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empties the table into `out` and returns what it copied. The lock is
|
||||||
|
/// released before any database call, so the query path never waits on
|
||||||
|
/// SQLite.
|
||||||
|
fn drain(self: *Tracker, io: std.Io, out: *[max_pending]Pending) []const Pending {
|
||||||
|
self.mutex.lockUncancelable(io);
|
||||||
|
defer self.mutex.unlock(io);
|
||||||
|
|
||||||
|
const n = self.count;
|
||||||
|
@memcpy(out[0..n], self.pending[0..n]);
|
||||||
|
self.count = 0;
|
||||||
|
return out[0..n];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const migrations = @import("../storage/migrations.zig");
|
||||||
|
|
||||||
|
const testing = std.testing;
|
||||||
|
|
||||||
|
fn openMigrated() !db.Db {
|
||||||
|
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||||
|
errdefer database.close();
|
||||||
|
try db.applyPragmas(&database, .{});
|
||||||
|
_ = try migrations.migrate(&database);
|
||||||
|
return database;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lastSeen(database: *db.Db, ip: []const u8) !i64 {
|
||||||
|
var stmt = try database.prepare("SELECT last_seen FROM clients WHERE ip = ?1");
|
||||||
|
defer stmt.deinit();
|
||||||
|
try stmt.bindText(1, ip);
|
||||||
|
try testing.expect(try stmt.step());
|
||||||
|
return stmt.columnInt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parsed(text: []const u8) address.NetAddress {
|
||||||
|
return address.NetAddress.parse(text) catch unreachable;
|
||||||
|
}
|
||||||
|
|
||||||
|
test "a client tracked twice before a flush yields one row at the later time" {
|
||||||
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var database = try openMigrated();
|
||||||
|
defer database.close();
|
||||||
|
|
||||||
|
var tracker: Tracker = .init(30);
|
||||||
|
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||||
|
tracker.trackAt(io, parsed("192.168.1.10"), 1700000030);
|
||||||
|
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
|
||||||
|
|
||||||
|
tracker.flushOnce(io, &database, true);
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
|
||||||
|
try testing.expectEqual(@as(i64, 1700000030), try lastSeen(&database, "192.168.1.10"));
|
||||||
|
|
||||||
|
const stats = tracker.snapshotStats(io);
|
||||||
|
try testing.expectEqual(@as(u64, 2), stats.tracked);
|
||||||
|
try testing.expectEqual(@as(u64, 1), stats.flushed);
|
||||||
|
try testing.expectEqual(@as(u64, 0), stats.flush_failures);
|
||||||
|
try testing.expectEqual(@as(u32, 0), tracker.pendingClients(io));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "distinct clients each get a row and ipv6 text is canonical" {
|
||||||
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var database = try openMigrated();
|
||||||
|
defer database.close();
|
||||||
|
|
||||||
|
var tracker: Tracker = .init(30);
|
||||||
|
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||||
|
tracker.trackAt(io, parsed("192.168.1.11"), 1700000001);
|
||||||
|
tracker.trackAt(io, parsed("fd00:0:0:0:0:0:0:1"), 1700000002);
|
||||||
|
// An IPv4-mapped literal is the same client as its plain form.
|
||||||
|
tracker.trackAt(io, address.NetAddress.fromIp(try std.Io.net.IpAddress.parse("::ffff:192.168.1.10", 53)), 1700000003);
|
||||||
|
try testing.expectEqual(@as(u32, 3), tracker.pendingClients(io));
|
||||||
|
|
||||||
|
tracker.flushOnce(io, &database, true);
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(i64, 3), try clients_repo.countClients(&database));
|
||||||
|
try testing.expectEqual(@as(i64, 1700000003), try lastSeen(&database, "192.168.1.10"));
|
||||||
|
try testing.expectEqual(@as(i64, 1700000002), try lastSeen(&database, "fd00::1"));
|
||||||
|
try testing.expectEqual(@as(u64, 3), tracker.snapshotStats(io).flushed);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "a full table drops further clients and counts them" {
|
||||||
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var database = try openMigrated();
|
||||||
|
defer database.close();
|
||||||
|
|
||||||
|
var tracker: Tracker = .init(30);
|
||||||
|
for (0..Tracker.max_pending) |i| {
|
||||||
|
var octets: [4]u8 = undefined;
|
||||||
|
std.mem.writeInt(u32, &octets, @intCast(i), .big);
|
||||||
|
tracker.trackAt(io, .{ .ip4 = octets }, 1700000000);
|
||||||
|
}
|
||||||
|
try testing.expectEqual(@as(u32, Tracker.max_pending), tracker.pendingClients(io));
|
||||||
|
try testing.expectEqual(@as(u64, 0), tracker.snapshotStats(io).dropped_full);
|
||||||
|
|
||||||
|
tracker.trackAt(io, parsed("203.0.113.7"), 1700000000);
|
||||||
|
tracker.trackAt(io, parsed("203.0.113.8"), 1700000000);
|
||||||
|
try testing.expectEqual(@as(u32, Tracker.max_pending), tracker.pendingClients(io));
|
||||||
|
|
||||||
|
const stats = tracker.snapshotStats(io);
|
||||||
|
try testing.expectEqual(@as(u64, 2), stats.dropped_full);
|
||||||
|
try testing.expectEqual(@as(u64, Tracker.max_pending), stats.tracked);
|
||||||
|
|
||||||
|
// A tracked client still refreshes while the table is full, and the flush
|
||||||
|
// makes room for the next newcomer.
|
||||||
|
tracker.trackAt(io, .{ .ip4 = .{ 0, 0, 0, 0 } }, 1700000060);
|
||||||
|
tracker.flushOnce(io, &database, true);
|
||||||
|
try testing.expectEqual(@as(i64, Tracker.max_pending), try clients_repo.countClients(&database));
|
||||||
|
try testing.expectEqual(@as(i64, 1700000060), try lastSeen(&database, "0.0.0.0"));
|
||||||
|
|
||||||
|
tracker.trackAt(io, parsed("203.0.113.7"), 1700000060);
|
||||||
|
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "a flush touches a hand-edited row without changing what the operator set" {
|
||||||
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var database = try openMigrated();
|
||||||
|
defer database.close();
|
||||||
|
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
|
||||||
|
try database.exec(
|
||||||
|
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
|
||||||
|
\\VALUES ('192.168.1.10', 'laptop', 2, 1, 1690000000, 1690000000);
|
||||||
|
);
|
||||||
|
|
||||||
|
var tracker: Tracker = .init(30);
|
||||||
|
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||||
|
tracker.flushOnce(io, &database, true);
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
|
||||||
|
try testing.expectEqual(@as(i64, 1700000000), try lastSeen(&database, "192.168.1.10"));
|
||||||
|
try testing.expectEqual(
|
||||||
|
@as(i64, 1),
|
||||||
|
try database.queryInt(
|
||||||
|
\\SELECT count(*) FROM clients
|
||||||
|
\\ WHERE ip = '192.168.1.10' AND name = 'laptop' AND group_id = 2
|
||||||
|
\\ AND hand_edited = 1 AND first_seen = 1690000000
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "a gated pass writes nothing and keeps the pending clients" {
|
||||||
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var database = try openMigrated();
|
||||||
|
defer database.close();
|
||||||
|
|
||||||
|
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
|
||||||
|
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
|
||||||
|
try testing.expect(!monitor.writesAllowed());
|
||||||
|
|
||||||
|
var tracker: Tracker = .init(30);
|
||||||
|
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||||
|
tracker.flushOnce(io, &database, monitor.writesAllowed());
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
|
||||||
|
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
|
||||||
|
try testing.expectEqual(@as(u64, 0), tracker.snapshotStats(io).flushed);
|
||||||
|
try testing.expectEqual(@as(u64, 0), tracker.passes);
|
||||||
|
|
||||||
|
// Free space recovers and the same pending client lands.
|
||||||
|
monitor.state_raw.store(@intFromEnum(disk_monitor.State.warn), .monotonic);
|
||||||
|
tracker.flushOnce(io, &database, monitor.writesAllowed());
|
||||||
|
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
|
||||||
|
try testing.expectEqual(@as(u64, 1), tracker.passes);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "a failing upsert counts and leaves the client to be tracked again" {
|
||||||
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var database = try openMigrated();
|
||||||
|
defer database.close();
|
||||||
|
try database.exec(
|
||||||
|
\\CREATE TRIGGER refuse_insert BEFORE INSERT ON clients
|
||||||
|
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
||||||
|
);
|
||||||
|
|
||||||
|
var tracker: Tracker = .init(30);
|
||||||
|
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||||
|
tracker.trackAt(io, parsed("192.168.1.11"), 1700000000);
|
||||||
|
tracker.flushOnce(io, &database, true);
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
|
||||||
|
const stats = tracker.snapshotStats(io);
|
||||||
|
try testing.expectEqual(@as(u64, 0), stats.flushed);
|
||||||
|
try testing.expectEqual(@as(u64, 2), stats.flush_failures);
|
||||||
|
try testing.expectEqual(@as(u32, 0), tracker.pendingClients(io));
|
||||||
|
|
||||||
|
try database.exec("DROP TRIGGER refuse_insert;");
|
||||||
|
tracker.trackAt(io, parsed("192.168.1.10"), 1700000060);
|
||||||
|
tracker.flushOnce(io, &database, true);
|
||||||
|
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "the pass that comes due prunes the clients that went quiet" {
|
||||||
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var database = try openMigrated();
|
||||||
|
defer database.close();
|
||||||
|
|
||||||
|
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||||
|
const day = 86_400;
|
||||||
|
try clients_repo.upsertSeen(&database, "10.0.0.1", now - 40 * day);
|
||||||
|
try clients_repo.upsertSeen(&database, "10.0.0.2", now - 29 * day);
|
||||||
|
|
||||||
|
var tracker: Tracker = .init(30);
|
||||||
|
// Every pass before the due one leaves both rows alone.
|
||||||
|
for (0..Tracker.prune_every_passes - 1) |_| {
|
||||||
|
tracker.flushOnce(io, &database, true);
|
||||||
|
}
|
||||||
|
try testing.expectEqual(@as(i64, 2), try clients_repo.countClients(&database));
|
||||||
|
try testing.expectEqual(@as(u64, 0), tracker.snapshotStats(io).pruned);
|
||||||
|
|
||||||
|
tracker.flushOnce(io, &database, true);
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
|
||||||
|
try testing.expectEqual(@as(i64, now - 29 * day), try lastSeen(&database, "10.0.0.2"));
|
||||||
|
try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).pruned);
|
||||||
|
try testing.expectEqual(@as(u64, Tracker.prune_every_passes), tracker.passes);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "a shorter retention prunes what the default keeps" {
|
||||||
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var database = try openMigrated();
|
||||||
|
defer database.close();
|
||||||
|
|
||||||
|
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||||
|
try clients_repo.upsertSeen(&database, "10.0.0.1", now - 3 * 86_400);
|
||||||
|
|
||||||
|
var tracker: Tracker = .init(1);
|
||||||
|
tracker.passes = Tracker.prune_every_passes - 1;
|
||||||
|
tracker.flushOnce(io, &database, true);
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
|
||||||
|
try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).pruned);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "the run loop flushes on its interval and returns on cancel" {
|
||||||
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var database = try openMigrated();
|
||||||
|
defer database.close();
|
||||||
|
|
||||||
|
var tracker: Tracker = .init(30);
|
||||||
|
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||||
|
|
||||||
|
var future = try io.concurrent(Tracker.run, .{
|
||||||
|
&tracker,
|
||||||
|
io,
|
||||||
|
&database,
|
||||||
|
@as(?*disk_monitor.Monitor, null),
|
||||||
|
});
|
||||||
|
// The first flush is one interval away, so cancelling immediately proves the
|
||||||
|
// loop starts by sleeping rather than by writing.
|
||||||
|
try testing.expectError(error.Canceled, future.cancel(io));
|
||||||
|
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
|
||||||
|
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
|
||||||
|
}
|
||||||
+1951
-83
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
|||||||
|
//! Global pause of filtering (PLAN §13.1). One 64-bit flag the query path
|
||||||
|
//! reads and the API writes. Pure: no allocation, no `std.Io`, no clock — the
|
||||||
|
//! caller supplies the time, because the handler already has it.
|
||||||
|
//!
|
||||||
|
//! Pausing suspends FILTERING only: local records, forward zones, the cache,
|
||||||
|
//! the upstream and the query log all keep running (milestone-7 ruling 18).
|
||||||
|
//!
|
||||||
|
//! The state is in memory and is deliberately not persisted: a restart resumes
|
||||||
|
//! filtering, which is the safe default for a household.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
pub const Pause = struct {
|
||||||
|
/// 0 = filtering active. -1 = paused until someone resumes. Any other value
|
||||||
|
/// is the unix second filtering resumes at, so the expiry is a comparison
|
||||||
|
/// on the query path rather than a timer task.
|
||||||
|
until: std.atomic.Value(i64) = .init(0),
|
||||||
|
|
||||||
|
/// `.monotonic` throughout: the flag guards no other data, so nothing has
|
||||||
|
/// to be ordered against it.
|
||||||
|
pub fn isPaused(self: *const Pause, now_s: i64) bool {
|
||||||
|
const until = self.until.load(.monotonic);
|
||||||
|
if (until == 0) return false;
|
||||||
|
if (until < 0) return true;
|
||||||
|
return now_s < until;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `null` pauses until `unpause`. A pause already in force is replaced, so
|
||||||
|
/// the last call wins whether it lengthens or shortens the pause.
|
||||||
|
pub fn pauseFor(self: *Pause, now_s: i64, duration_s: ?u32) void {
|
||||||
|
const value: i64 = if (duration_s) |seconds| now_s +| @as(i64, seconds) else -1;
|
||||||
|
self.until.store(value, .monotonic);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unpause(self: *Pause) void {
|
||||||
|
self.until.store(0, .monotonic);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testing = std.testing;
|
||||||
|
|
||||||
|
test "a fresh pause is not paused" {
|
||||||
|
const p: Pause = .{};
|
||||||
|
try testing.expect(!p.isPaused(0));
|
||||||
|
try testing.expect(!p.isPaused(1_700_000_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "an indefinite pause holds at every time" {
|
||||||
|
var p: Pause = .{};
|
||||||
|
p.pauseFor(1_700_000_000, null);
|
||||||
|
try testing.expect(p.isPaused(1_700_000_000));
|
||||||
|
try testing.expect(p.isPaused(1_700_000_000 + 86_400 * 365));
|
||||||
|
try testing.expectEqual(@as(i64, -1), p.until.load(.monotonic));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "a timed pause expires at its own second" {
|
||||||
|
var p: Pause = .{};
|
||||||
|
p.pauseFor(1_000, 60);
|
||||||
|
try testing.expect(p.isPaused(1_000));
|
||||||
|
try testing.expect(p.isPaused(1_059));
|
||||||
|
// The stored second is when filtering is back on, so it is not paused.
|
||||||
|
try testing.expect(!p.isPaused(1_060));
|
||||||
|
try testing.expect(!p.isPaused(1_061));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "unpause resumes both kinds of pause" {
|
||||||
|
var p: Pause = .{};
|
||||||
|
p.pauseFor(1_000, null);
|
||||||
|
p.unpause();
|
||||||
|
try testing.expect(!p.isPaused(1_000));
|
||||||
|
|
||||||
|
p.pauseFor(1_000, 60);
|
||||||
|
p.unpause();
|
||||||
|
try testing.expect(!p.isPaused(1_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "pauseFor overwrites a pause already in force" {
|
||||||
|
var p: Pause = .{};
|
||||||
|
p.pauseFor(1_000, 3_600);
|
||||||
|
p.pauseFor(1_000, 10);
|
||||||
|
try testing.expect(!p.isPaused(1_010));
|
||||||
|
|
||||||
|
// And in the other direction: indefinite replaces a timed pause.
|
||||||
|
p.pauseFor(1_000, 10);
|
||||||
|
p.pauseFor(1_000, null);
|
||||||
|
try testing.expect(p.isPaused(1_010));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "a duration that would overflow saturates instead of wrapping" {
|
||||||
|
var p: Pause = .{};
|
||||||
|
p.pauseFor(std.math.maxInt(i64), std.math.maxInt(u32));
|
||||||
|
try testing.expect(p.isPaused(std.math.maxInt(i64) - 1));
|
||||||
|
}
|
||||||
@@ -0,0 +1,996 @@
|
|||||||
|
//! Milestone-7 integration tests (spec S7): the serving pipeline end to end,
|
||||||
|
//! over real sockets.
|
||||||
|
//!
|
||||||
|
//! This lives in its own file because it needs `@import("build_options")`, which
|
||||||
|
//! only exists when the compilation is driven by `build.zig`. The body compiles
|
||||||
|
//! on every `zig build test` run, so it cannot rot, and every case skips at run
|
||||||
|
//! time unless `-Dintegration` is passed.
|
||||||
|
//!
|
||||||
|
//! What separates these cases from `handler.zig`'s own tests is the socket. The
|
||||||
|
//! handler tests call `handle` directly; here every query travels through a real
|
||||||
|
//! `UdpServer` on 127.0.0.1, through the real handler with its real cache,
|
||||||
|
//! limiter, tracker and query log, and the reply is read back off the wire. The
|
||||||
|
//! upstream is a `transport.Client` fixture, except in the forward-zone case,
|
||||||
|
//! where the zone resolver has to be a real UDP socket because `ForwardClient`
|
||||||
|
//! speaks wire DNS to an address.
|
||||||
|
//!
|
||||||
|
//! Hermetic: every socket is bound to 127.0.0.1, every database is in memory or
|
||||||
|
//! inside a `std.testing.tmpDir`, and every wait carries a budget.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const build_options = @import("build_options");
|
||||||
|
const net = std.Io.net;
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
|
const app = @import("../app.zig");
|
||||||
|
const cli = @import("../cli.zig");
|
||||||
|
const clients = @import("clients.zig");
|
||||||
|
const clients_repo = @import("../storage/repositories/clients_repo.zig");
|
||||||
|
const db = @import("../storage/db.zig");
|
||||||
|
const dns_cache = @import("../cache/dns_cache.zig");
|
||||||
|
const forward_zones = @import("../local/forward_zones.zig");
|
||||||
|
const handler = @import("handler.zig");
|
||||||
|
const header = @import("../dns/header.zig");
|
||||||
|
const logger_mod = @import("../storage/logger.zig");
|
||||||
|
const manager = @import("../filter/manager.zig");
|
||||||
|
const matcher = @import("../filter/matcher.zig");
|
||||||
|
const migrations = @import("../storage/migrations.zig");
|
||||||
|
const model = @import("../config/model.zig");
|
||||||
|
const name = @import("../dns/name.zig");
|
||||||
|
const packet = @import("../dns/packet.zig");
|
||||||
|
const pause = @import("pause.zig");
|
||||||
|
const question = @import("../dns/question.zig");
|
||||||
|
const rate_limiter = @import("rate_limiter.zig");
|
||||||
|
const record = @import("../dns/record.zig");
|
||||||
|
const records = @import("../local/records.zig");
|
||||||
|
const response = @import("../filter/response.zig");
|
||||||
|
const shutdown = @import("shutdown.zig");
|
||||||
|
const transport = @import("../upstream/transport.zig");
|
||||||
|
const types = @import("../dns/types.zig");
|
||||||
|
const udp_server = @import("udp_server.zig");
|
||||||
|
|
||||||
|
const testing = std.testing;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// shared fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Long enough that a loopback round trip cannot lose to scheduling, short
|
||||||
|
/// enough that a broken server fails the run instead of hanging it.
|
||||||
|
const budget: std.Io.Timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } };
|
||||||
|
|
||||||
|
const empty_records: records.Records = .empty;
|
||||||
|
const empty_zones: forward_zones.Zones = .empty;
|
||||||
|
|
||||||
|
/// A five-second TTL makes the blocking answer's TTL unmistakable next to the
|
||||||
|
/// upstream's 300.
|
||||||
|
const blocking: response.Options = .{ .mode = .zero, .ttl = 5 };
|
||||||
|
|
||||||
|
const forward_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(2), .clock = .awake };
|
||||||
|
|
||||||
|
/// What every fake upstream answers with, and the TTL it carries.
|
||||||
|
const upstream_rdata = [4]u8{ 93, 184, 216, 34 };
|
||||||
|
const upstream_ttl: u32 = 300;
|
||||||
|
|
||||||
|
/// The zone resolver's answer, distinct from the pool's so a case can tell
|
||||||
|
/// which of the two replied.
|
||||||
|
const zone_rdata = [4]u8{ 10, 0, 0, 7 };
|
||||||
|
const zone_ttl: u32 = 120;
|
||||||
|
|
||||||
|
/// The handler every case starts from: an upstream, the blocking options and
|
||||||
|
/// the empty local tables. Each case wires in the collaborators it exercises.
|
||||||
|
fn baseHandler(client: transport.Client) handler.Handler {
|
||||||
|
return .{
|
||||||
|
.upstream = client,
|
||||||
|
.blocking = blocking,
|
||||||
|
.forward_read_timeout = forward_timeout,
|
||||||
|
.records = &empty_records,
|
||||||
|
.zones = &empty_zones,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A real listener, a real client socket and the task that serves them.
|
||||||
|
///
|
||||||
|
/// Two phases: `bind` produces the value, `start` spawns the serve task against
|
||||||
|
/// its final address. Nothing may copy a `Loop` after `start`, because the task
|
||||||
|
/// holds a pointer into it.
|
||||||
|
const Loop = struct {
|
||||||
|
server: udp_server.UdpServer,
|
||||||
|
group: std.Io.Group,
|
||||||
|
client: net.Socket,
|
||||||
|
server_address: net.IpAddress,
|
||||||
|
|
||||||
|
fn bind(gpa: Allocator, io: std.Io, h: *handler.Handler) !Loop {
|
||||||
|
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||||
|
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, h, .{ .max_in_flight = 4 });
|
||||||
|
errdefer server.deinit(gpa, io);
|
||||||
|
|
||||||
|
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||||
|
const client = try client_address.bind(io, .{ .mode = .dgram });
|
||||||
|
|
||||||
|
return .{
|
||||||
|
.server = server,
|
||||||
|
.group = .init,
|
||||||
|
.client = client,
|
||||||
|
.server_address = server.boundAddress(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(self: *Loop, io: std.Io) !void {
|
||||||
|
try self.group.concurrent(io, udp_server.UdpServer.serve, .{ &self.server, io });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One query, one reply. The reply is a prefix of `buf`.
|
||||||
|
fn ask(self: *Loop, io: std.Io, query: []const u8, buf: []u8) ![]u8 {
|
||||||
|
try self.client.send(io, &self.server_address, query);
|
||||||
|
const msg = try self.client.receiveTimeout(io, buf, budget);
|
||||||
|
return msg.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(self: *Loop, gpa: Allocator, io: std.Io) void {
|
||||||
|
self.server.deinit(gpa, io);
|
||||||
|
self.group.cancel(io);
|
||||||
|
self.client.close(io);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A query for `domain`, RD set, one question, no OPT.
|
||||||
|
fn queryFor(buf: []u8, id: u16, domain: []const u8, qtype: types.Type) []const u8 {
|
||||||
|
var w: std.Io.Writer = .fixed(buf);
|
||||||
|
var encoded: [types.header_len]u8 = undefined;
|
||||||
|
header.encode(.{
|
||||||
|
.id = id,
|
||||||
|
.flags = .{
|
||||||
|
.rcode = .no_error,
|
||||||
|
.z = 0,
|
||||||
|
.ra = false,
|
||||||
|
.rd = true,
|
||||||
|
.tc = false,
|
||||||
|
.aa = false,
|
||||||
|
.opcode = .query,
|
||||||
|
.qr = false,
|
||||||
|
},
|
||||||
|
.qdcount = 1,
|
||||||
|
.ancount = 0,
|
||||||
|
.nscount = 0,
|
||||||
|
.arcount = 0,
|
||||||
|
}, &encoded);
|
||||||
|
w.writeAll(&encoded) catch unreachable;
|
||||||
|
question.encode(.{
|
||||||
|
.name = name.fromText(domain) catch unreachable,
|
||||||
|
.qtype = qtype,
|
||||||
|
.qclass = .in,
|
||||||
|
}, &w) catch unreachable;
|
||||||
|
return w.buffered();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pool stand-in. It answers the question it is given rather than a fixed
|
||||||
|
/// byte string, because the safe-search and uncloaking cases both change the
|
||||||
|
/// question on the way out.
|
||||||
|
///
|
||||||
|
/// `calls` is atomic: the listener task runs on another thread than the one
|
||||||
|
/// asserting.
|
||||||
|
const FakeUpstream = struct {
|
||||||
|
reply: Reply,
|
||||||
|
calls: std.atomic.Value(u64) = .init(0),
|
||||||
|
|
||||||
|
const Reply = union(enum) {
|
||||||
|
/// One A record for the queried name.
|
||||||
|
a,
|
||||||
|
/// One CNAME record for the queried name, pointing at this target.
|
||||||
|
cname: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn exchangeFn(
|
||||||
|
ptr: *anyopaque,
|
||||||
|
io: std.Io,
|
||||||
|
query: []const u8,
|
||||||
|
response_buf: []u8,
|
||||||
|
) transport.ExchangeError![]u8 {
|
||||||
|
_ = io;
|
||||||
|
const self: *FakeUpstream = @ptrCast(@alignCast(ptr));
|
||||||
|
_ = self.calls.fetchAdd(1, .monotonic);
|
||||||
|
|
||||||
|
const request = packet.parse(query) catch return error.BadResponse;
|
||||||
|
const q = packet.firstQuestion(request) orelse return error.BadResponse;
|
||||||
|
|
||||||
|
var b = packet.ResponseBuilder.init(response_buf, request.header, q) catch
|
||||||
|
return error.ResponseTooLarge;
|
||||||
|
switch (self.reply) {
|
||||||
|
.a => b.addAnswer(q.name, .a, .in, upstream_ttl, &upstream_rdata) catch
|
||||||
|
return error.ResponseTooLarge,
|
||||||
|
.cname => |target| {
|
||||||
|
const t = name.fromText(target) catch return error.BadResponse;
|
||||||
|
b.addAnswer(q.name, .cname, .in, upstream_ttl, t.wire()) catch
|
||||||
|
return error.ResponseTooLarge;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return b.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client(self: *FakeUpstream) transport.Client {
|
||||||
|
return .{ .ptr = self, .exchangeFn = exchangeFn };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The forward zone's resolver: a real UDP socket, because `ForwardClient`
|
||||||
|
/// speaks wire DNS to an address and nothing smaller would prove it did.
|
||||||
|
///
|
||||||
|
/// The loop ends when the receive is canceled, which is what `group.cancel`
|
||||||
|
/// does at the end of the case.
|
||||||
|
fn zoneResolver(io: std.Io, socket: *const net.Socket, calls: *std.atomic.Value(u64)) void {
|
||||||
|
var buf: [udp_server.max_datagram]u8 = undefined;
|
||||||
|
while (true) {
|
||||||
|
const msg = socket.receive(io, &buf) catch return;
|
||||||
|
_ = calls.fetchAdd(1, .monotonic);
|
||||||
|
|
||||||
|
const request = packet.parse(msg.data) catch continue;
|
||||||
|
const q = packet.firstQuestion(request) orelse continue;
|
||||||
|
|
||||||
|
var reply_buf: [512]u8 = undefined;
|
||||||
|
var b = packet.ResponseBuilder.init(&reply_buf, request.header, q) catch continue;
|
||||||
|
b.addAnswer(q.name, .a, .in, zone_ttl, &zone_rdata) catch continue;
|
||||||
|
socket.send(io, &msg.from, b.finish()) catch return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SnapshotFixture = struct {
|
||||||
|
groups: []const model.Group = &.{.{ .name = "default" }},
|
||||||
|
rules: []const model.Rule = &.{},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn buildSnapshot(gpa: Allocator, fixture: SnapshotFixture) !matcher.Snapshot {
|
||||||
|
return matcher.Snapshot.build(gpa, .{
|
||||||
|
.groups = fixture.groups,
|
||||||
|
.group_ids = &.{1},
|
||||||
|
.group_sources = &.{},
|
||||||
|
.sources = &.{},
|
||||||
|
.source_ids = &.{},
|
||||||
|
.rules = fixture.rules,
|
||||||
|
.clients = &.{},
|
||||||
|
.prefixes = &.{},
|
||||||
|
.compiled = &.{},
|
||||||
|
.seed = 0x5eed,
|
||||||
|
.generation = 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Manager.acquire` reads the manager's lock and its current snapshot and
|
||||||
|
/// nothing else, so a manager that publishes one hand-built snapshot needs
|
||||||
|
/// none of the database, fetcher or blocklist directory the real one owns.
|
||||||
|
fn fixtureManager(m: *manager.Manager, snapshot: *matcher.Snapshot) void {
|
||||||
|
m.* = .{
|
||||||
|
.gpa = testing.allocator,
|
||||||
|
.database = undefined,
|
||||||
|
.paths = undefined,
|
||||||
|
.fetcher = undefined,
|
||||||
|
.update = .{},
|
||||||
|
.total_budget = forward_timeout,
|
||||||
|
.lock = .init,
|
||||||
|
.writer_lock = .init,
|
||||||
|
.current = snapshot,
|
||||||
|
.generation = 1,
|
||||||
|
.statuses = &.{},
|
||||||
|
.status_arena = .init(testing.allocator),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blockRule(pattern: []const u8) model.Rule {
|
||||||
|
return .{ .group = "default", .pattern = pattern, .kind = .exact, .action = .block };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allowRule(pattern: []const u8) model.Rule {
|
||||||
|
return .{ .group = "default", .pattern = pattern, .kind = .exact, .action = .allow };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn firstAnswer(p: packet.Packet) !record.Record {
|
||||||
|
var it = packet.answers(p);
|
||||||
|
return (try it.next()) orelse error.TestExpectedAnswer;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drainLog(lg: *logger_mod.Logger, io: std.Io, out: []logger_mod.Entry) []logger_mod.Entry {
|
||||||
|
const n = lg.queue.getUncancelable(io, out, 0) catch 0;
|
||||||
|
return out[0..n];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every case that asserts on the query log wants the same shape: a queue big
|
||||||
|
/// enough to hold the whole case, drained once at the end.
|
||||||
|
const log_queue_len = 8;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// case 1: blocked domain
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test "S7 case 1: a blocked domain is answered with the zero address and logged" {
|
||||||
|
if (!build_options.integration) return error.SkipZigTest;
|
||||||
|
|
||||||
|
const gpa = testing.allocator;
|
||||||
|
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var snapshot = try buildSnapshot(gpa, .{ .rules = &.{blockRule("ads.example.com")} });
|
||||||
|
defer snapshot.deinit();
|
||||||
|
var mgr: manager.Manager = undefined;
|
||||||
|
fixtureManager(&mgr, &snapshot);
|
||||||
|
|
||||||
|
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||||
|
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||||
|
|
||||||
|
var fake: FakeUpstream = .{ .reply = .a };
|
||||||
|
var h = baseHandler(fake.client());
|
||||||
|
h.manager = &mgr;
|
||||||
|
h.logger = ≶
|
||||||
|
|
||||||
|
var loop = try Loop.bind(gpa, io, &h);
|
||||||
|
defer loop.stop(gpa, io);
|
||||||
|
try loop.start(io);
|
||||||
|
|
||||||
|
var query_buf: [512]u8 = undefined;
|
||||||
|
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||||
|
const reply = try loop.ask(io, queryFor(&query_buf, 0x1111, "ads.example.com", .a), &reply_buf);
|
||||||
|
|
||||||
|
const p = try packet.parse(reply);
|
||||||
|
try testing.expectEqual(@as(u16, 0x1111), p.header.id);
|
||||||
|
try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
|
||||||
|
try testing.expectEqual(@as(u16, 1), p.header.ancount);
|
||||||
|
|
||||||
|
const answer = try firstAnswer(p);
|
||||||
|
try testing.expectEqual(@as(u32, blocking.ttl), answer.ttl);
|
||||||
|
try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, try record.rdataA(p.bytes, answer));
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(u64, 0), fake.calls.load(.monotonic));
|
||||||
|
try testing.expectEqual(@as(u64, 1), h.stats.blocked.load(.monotonic));
|
||||||
|
|
||||||
|
var entries: [log_queue_len]logger_mod.Entry = undefined;
|
||||||
|
const logged = drainLog(&lg, io, &entries);
|
||||||
|
try testing.expectEqual(@as(usize, 1), logged.len);
|
||||||
|
try testing.expectEqual(true, logged[0].blocked);
|
||||||
|
try testing.expectEqualStrings("ads.example.com", logged[0].domain());
|
||||||
|
try testing.expectEqualStrings("rule_block_exact", logged[0].blockReason());
|
||||||
|
try testing.expectEqualStrings("127.0.0.1", logged[0].clientIp());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// case 2: allow over block
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test "S7 case 2: an allow rule beats the blocklist and the upstream answers" {
|
||||||
|
if (!build_options.integration) return error.SkipZigTest;
|
||||||
|
|
||||||
|
const gpa = testing.allocator;
|
||||||
|
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var snapshot = try buildSnapshot(gpa, .{
|
||||||
|
.rules = &.{ blockRule("com"), allowRule("example.com") },
|
||||||
|
});
|
||||||
|
defer snapshot.deinit();
|
||||||
|
var mgr: manager.Manager = undefined;
|
||||||
|
fixtureManager(&mgr, &snapshot);
|
||||||
|
|
||||||
|
var fake: FakeUpstream = .{ .reply = .a };
|
||||||
|
var h = baseHandler(fake.client());
|
||||||
|
h.manager = &mgr;
|
||||||
|
|
||||||
|
var loop = try Loop.bind(gpa, io, &h);
|
||||||
|
defer loop.stop(gpa, io);
|
||||||
|
try loop.start(io);
|
||||||
|
|
||||||
|
var query_buf: [512]u8 = undefined;
|
||||||
|
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||||
|
const reply = try loop.ask(io, queryFor(&query_buf, 0x2222, "example.com", .a), &reply_buf);
|
||||||
|
|
||||||
|
const p = try packet.parse(reply);
|
||||||
|
const answer = try firstAnswer(p);
|
||||||
|
try testing.expectEqual(upstream_rdata, try record.rdataA(p.bytes, answer));
|
||||||
|
try testing.expectEqual(upstream_ttl, answer.ttl);
|
||||||
|
try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic));
|
||||||
|
try testing.expectEqual(@as(u64, 0), h.stats.blocked.load(.monotonic));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// case 3: local records
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test "S7 case 3: a local record answers authoritatively without an upstream" {
|
||||||
|
if (!build_options.integration) return error.SkipZigTest;
|
||||||
|
|
||||||
|
const gpa = testing.allocator;
|
||||||
|
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var table = try records.Records.build(gpa, &.{
|
||||||
|
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 60 },
|
||||||
|
});
|
||||||
|
defer table.deinit(gpa);
|
||||||
|
|
||||||
|
var fake: FakeUpstream = .{ .reply = .a };
|
||||||
|
var h = baseHandler(fake.client());
|
||||||
|
h.records = &table;
|
||||||
|
|
||||||
|
var loop = try Loop.bind(gpa, io, &h);
|
||||||
|
defer loop.stop(gpa, io);
|
||||||
|
try loop.start(io);
|
||||||
|
|
||||||
|
var query_buf: [512]u8 = undefined;
|
||||||
|
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||||
|
const reply = try loop.ask(io, queryFor(&query_buf, 0x3333, "nas.lan", .a), &reply_buf);
|
||||||
|
|
||||||
|
const p = try packet.parse(reply);
|
||||||
|
try testing.expectEqual(true, p.header.flags.aa);
|
||||||
|
try testing.expectEqual(@as(u16, 1), p.header.ancount);
|
||||||
|
|
||||||
|
const answer = try firstAnswer(p);
|
||||||
|
try testing.expectEqual(@as(u32, 60), answer.ttl);
|
||||||
|
try testing.expectEqual([4]u8{ 192, 168, 1, 10 }, try record.rdataA(p.bytes, answer));
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(u64, 0), fake.calls.load(.monotonic));
|
||||||
|
try testing.expectEqual(@as(u64, 1), h.stats.local_answers.load(.monotonic));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// case 4: forward zones
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test "S7 case 4: a forward zone reaches its resolver, bypasses the blocklist and caches" {
|
||||||
|
if (!build_options.integration) return error.SkipZigTest;
|
||||||
|
|
||||||
|
const gpa = testing.allocator;
|
||||||
|
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
// The zone resolver is a socket of its own, so the case can tell a query
|
||||||
|
// that reached it from one the pool answered.
|
||||||
|
const resolver_bind: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||||
|
const resolver_socket = try resolver_bind.bind(io, .{ .mode = .dgram });
|
||||||
|
defer resolver_socket.close(io);
|
||||||
|
|
||||||
|
var resolver_calls: std.atomic.Value(u64) = .init(0);
|
||||||
|
var resolver_group: std.Io.Group = .init;
|
||||||
|
defer resolver_group.cancel(io);
|
||||||
|
try resolver_group.concurrent(io, zoneResolver, .{ io, &resolver_socket, &resolver_calls });
|
||||||
|
|
||||||
|
var resolver_text: [64]u8 = undefined;
|
||||||
|
const resolver_url = try std.fmt.bufPrint(&resolver_text, "udp://127.0.0.1:{d}", .{
|
||||||
|
resolver_socket.address.ip4.port,
|
||||||
|
});
|
||||||
|
|
||||||
|
var zones = try forward_zones.Zones.build(gpa, &.{
|
||||||
|
.{ .zone = "lan.home", .resolver = resolver_url },
|
||||||
|
});
|
||||||
|
defer zones.deinit(gpa);
|
||||||
|
|
||||||
|
// The name is blocklisted, so an answer from the zone resolver is proof the
|
||||||
|
// bypass (ruling 7) holds over the wire.
|
||||||
|
var snapshot = try buildSnapshot(gpa, .{ .rules = &.{blockRule("nas.lan.home")} });
|
||||||
|
defer snapshot.deinit();
|
||||||
|
var mgr: manager.Manager = undefined;
|
||||||
|
fixtureManager(&mgr, &snapshot);
|
||||||
|
|
||||||
|
var cache: dns_cache.DnsCache = try .init(gpa, .{ .size = 8, .negative_ttl_max = 3600 });
|
||||||
|
defer cache.deinit();
|
||||||
|
|
||||||
|
var fake: FakeUpstream = .{ .reply = .a };
|
||||||
|
var h = baseHandler(fake.client());
|
||||||
|
h.manager = &mgr;
|
||||||
|
h.zones = &zones;
|
||||||
|
h.cache = &cache;
|
||||||
|
h.negative_ttl_max = 3600;
|
||||||
|
|
||||||
|
var loop = try Loop.bind(gpa, io, &h);
|
||||||
|
defer loop.stop(gpa, io);
|
||||||
|
try loop.start(io);
|
||||||
|
|
||||||
|
var query_buf: [512]u8 = undefined;
|
||||||
|
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||||
|
const query = queryFor(&query_buf, 0x4444, "nas.lan.home", .a);
|
||||||
|
|
||||||
|
const first = try loop.ask(io, query, &reply_buf);
|
||||||
|
const p = try packet.parse(first);
|
||||||
|
try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
|
||||||
|
try testing.expectEqual(zone_rdata, try record.rdataA(p.bytes, try firstAnswer(p)));
|
||||||
|
try testing.expectEqual(@as(u64, 1), resolver_calls.load(.monotonic));
|
||||||
|
try testing.expectEqual(@as(u64, 0), fake.calls.load(.monotonic));
|
||||||
|
try testing.expectEqual(@as(u64, 0), h.stats.blocked.load(.monotonic));
|
||||||
|
try testing.expectEqual(@as(u32, 1), cache.len());
|
||||||
|
|
||||||
|
// The second query is answered from the cache: the resolver socket sees
|
||||||
|
// nothing more (PLAN §6.5).
|
||||||
|
var second_buf: [512]u8 = undefined;
|
||||||
|
const second_query = queryFor(&second_buf, 0x4455, "nas.lan.home", .a);
|
||||||
|
const second = try loop.ask(io, second_query, &reply_buf);
|
||||||
|
|
||||||
|
const second_p = try packet.parse(second);
|
||||||
|
try testing.expectEqual(@as(u16, 0x4455), second_p.header.id);
|
||||||
|
try testing.expectEqual(zone_rdata, try record.rdataA(second_p.bytes, try firstAnswer(second_p)));
|
||||||
|
try testing.expectEqual(@as(u64, 1), resolver_calls.load(.monotonic));
|
||||||
|
try testing.expectEqual(@as(u64, 1), h.stats.cache_hits.load(.monotonic));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// case 5: cache
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test "S7 case 5: a cached answer comes back with a fresh id, an aged ttl and a logged hit" {
|
||||||
|
if (!build_options.integration) return error.SkipZigTest;
|
||||||
|
|
||||||
|
const gpa = testing.allocator;
|
||||||
|
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var cache: dns_cache.DnsCache = try .init(gpa, .{ .size = 8, .negative_ttl_max = 3600 });
|
||||||
|
defer cache.deinit();
|
||||||
|
|
||||||
|
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||||
|
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||||
|
|
||||||
|
var fake: FakeUpstream = .{ .reply = .a };
|
||||||
|
var h = baseHandler(fake.client());
|
||||||
|
h.cache = &cache;
|
||||||
|
h.negative_ttl_max = 3600;
|
||||||
|
h.logger = ≶
|
||||||
|
|
||||||
|
var loop = try Loop.bind(gpa, io, &h);
|
||||||
|
defer loop.stop(gpa, io);
|
||||||
|
try loop.start(io);
|
||||||
|
|
||||||
|
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||||
|
|
||||||
|
// Miss, then hit under a different transaction ID.
|
||||||
|
var miss_buf: [512]u8 = undefined;
|
||||||
|
_ = try loop.ask(io, queryFor(&miss_buf, 0x5501, "example.com", .a), &reply_buf);
|
||||||
|
try testing.expectEqual(@as(u32, 1), cache.len());
|
||||||
|
try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic));
|
||||||
|
|
||||||
|
var hit_buf: [512]u8 = undefined;
|
||||||
|
const hit = try loop.ask(io, queryFor(&hit_buf, 0x5502, "example.com", .a), &reply_buf);
|
||||||
|
const p = try packet.parse(hit);
|
||||||
|
try testing.expectEqual(@as(u16, 0x5502), p.header.id);
|
||||||
|
try testing.expectEqual(upstream_rdata, try record.rdataA(p.bytes, try firstAnswer(p)));
|
||||||
|
try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic));
|
||||||
|
try testing.expectEqual(@as(u64, 1), h.stats.cache_hits.load(.monotonic));
|
||||||
|
|
||||||
|
// Ageing needs elapsed time, and a test cannot wait 10 seconds for it. The
|
||||||
|
// entry is therefore planted with a stored-at stamp 10 seconds in the past,
|
||||||
|
// under exactly the key the handler builds for this query.
|
||||||
|
var aged_query_buf: [512]u8 = undefined;
|
||||||
|
const aged_query = queryFor(&aged_query_buf, 0x5503, "aged.example.com", .a);
|
||||||
|
var stored_buf: [512]u8 = undefined;
|
||||||
|
const aged_p = try packet.parse(aged_query);
|
||||||
|
var b = try packet.ResponseBuilder.init(&stored_buf, aged_p.header, packet.firstQuestion(aged_p).?);
|
||||||
|
try b.addAnswer(try name.fromText("aged.example.com"), .a, .in, upstream_ttl, &upstream_rdata);
|
||||||
|
|
||||||
|
var key_buf: [dns_cache.max_key_len]u8 = undefined;
|
||||||
|
const key = dns_cache.buildKey(
|
||||||
|
&key_buf,
|
||||||
|
"aged.example.com",
|
||||||
|
@intFromEnum(types.Type.a),
|
||||||
|
@intFromEnum(types.Class.in),
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const aged_by = 10;
|
||||||
|
try cache.put(
|
||||||
|
std.Io.Clock.real.now(io).toSeconds() - aged_by,
|
||||||
|
key,
|
||||||
|
b.finish(),
|
||||||
|
.{ .ttl_seconds = upstream_ttl, .negative = false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const aged = try loop.ask(io, aged_query, &reply_buf);
|
||||||
|
const aged_reply = try packet.parse(aged);
|
||||||
|
try testing.expectEqual(upstream_ttl - aged_by, (try firstAnswer(aged_reply)).ttl);
|
||||||
|
try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic));
|
||||||
|
|
||||||
|
var entries: [log_queue_len]logger_mod.Entry = undefined;
|
||||||
|
const logged = drainLog(&lg, io, &entries);
|
||||||
|
try testing.expectEqual(@as(usize, 3), logged.len);
|
||||||
|
try testing.expectEqual(@as(?bool, false), logged[0].cache_hit);
|
||||||
|
try testing.expectEqualStrings("pool", logged[0].upstream());
|
||||||
|
try testing.expectEqual(@as(?bool, true), logged[1].cache_hit);
|
||||||
|
try testing.expectEqualStrings("", logged[1].upstream());
|
||||||
|
try testing.expectEqual(@as(?bool, true), logged[2].cache_hit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// case 6: CNAME uncloaking
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test "S7 case 6: a cname into a blocked target blocks the original question" {
|
||||||
|
if (!build_options.integration) return error.SkipZigTest;
|
||||||
|
|
||||||
|
const gpa = testing.allocator;
|
||||||
|
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var snapshot = try buildSnapshot(gpa, .{ .rules = &.{blockRule("tracker.example.org")} });
|
||||||
|
defer snapshot.deinit();
|
||||||
|
var mgr: manager.Manager = undefined;
|
||||||
|
fixtureManager(&mgr, &snapshot);
|
||||||
|
|
||||||
|
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||||
|
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||||
|
|
||||||
|
var fake: FakeUpstream = .{ .reply = .{ .cname = "tracker.example.org" } };
|
||||||
|
var h = baseHandler(fake.client());
|
||||||
|
h.manager = &mgr;
|
||||||
|
h.logger = ≶
|
||||||
|
|
||||||
|
var loop = try Loop.bind(gpa, io, &h);
|
||||||
|
defer loop.stop(gpa, io);
|
||||||
|
try loop.start(io);
|
||||||
|
|
||||||
|
var query_buf: [512]u8 = undefined;
|
||||||
|
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||||
|
const reply = try loop.ask(io, queryFor(&query_buf, 0x6666, "cdn.example.com", .a), &reply_buf);
|
||||||
|
|
||||||
|
const p = try packet.parse(reply);
|
||||||
|
try testing.expectEqual(@as(u16, 1), p.header.ancount);
|
||||||
|
|
||||||
|
// The answer is about the name the client asked for, not the target.
|
||||||
|
const answer = try firstAnswer(p);
|
||||||
|
try testing.expectEqual(types.Type.a, answer.rtype);
|
||||||
|
try testing.expectEqualSlices(
|
||||||
|
u8,
|
||||||
|
(try name.fromText("cdn.example.com")).wire(),
|
||||||
|
answer.name.wire(),
|
||||||
|
);
|
||||||
|
try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, try record.rdataA(p.bytes, answer));
|
||||||
|
try testing.expectEqual(@as(u64, 1), h.stats.uncloak_blocked.load(.monotonic));
|
||||||
|
|
||||||
|
var entries: [log_queue_len]logger_mod.Entry = undefined;
|
||||||
|
const logged = drainLog(&lg, io, &entries);
|
||||||
|
try testing.expectEqual(@as(usize, 1), logged.len);
|
||||||
|
try testing.expectEqual(true, logged[0].blocked);
|
||||||
|
try testing.expectEqualStrings("cname:rule_block_exact", logged[0].blockReason());
|
||||||
|
try testing.expectEqualStrings("cdn.example.com", logged[0].domain());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// case 7: safe search
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test "S7 case 7: safe search answers the original question with a cname to the target" {
|
||||||
|
if (!build_options.integration) return error.SkipZigTest;
|
||||||
|
|
||||||
|
const gpa = testing.allocator;
|
||||||
|
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var snapshot = try buildSnapshot(gpa, .{
|
||||||
|
.groups = &.{.{ .name = "default", .safe_search = true }},
|
||||||
|
});
|
||||||
|
defer snapshot.deinit();
|
||||||
|
var mgr: manager.Manager = undefined;
|
||||||
|
fixtureManager(&mgr, &snapshot);
|
||||||
|
|
||||||
|
var fake: FakeUpstream = .{ .reply = .a };
|
||||||
|
var h = baseHandler(fake.client());
|
||||||
|
h.manager = &mgr;
|
||||||
|
|
||||||
|
var loop = try Loop.bind(gpa, io, &h);
|
||||||
|
defer loop.stop(gpa, io);
|
||||||
|
try loop.start(io);
|
||||||
|
|
||||||
|
var query_buf: [512]u8 = undefined;
|
||||||
|
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||||
|
const reply = try loop.ask(io, queryFor(&query_buf, 0x7777, "www.google.com", .a), &reply_buf);
|
||||||
|
|
||||||
|
const p = try packet.parse(reply);
|
||||||
|
const target = try name.fromText("forcesafesearch.google.com");
|
||||||
|
|
||||||
|
// The reply keeps the question the client asked.
|
||||||
|
try testing.expectEqualSlices(
|
||||||
|
u8,
|
||||||
|
(try name.fromText("www.google.com")).wire(),
|
||||||
|
packet.firstQuestion(p).?.name.wire(),
|
||||||
|
);
|
||||||
|
try testing.expectEqual(@as(u16, 2), p.header.ancount);
|
||||||
|
|
||||||
|
var it = packet.answers(p);
|
||||||
|
const cname = (try it.next()).?;
|
||||||
|
try testing.expectEqual(types.Type.cname, cname.rtype);
|
||||||
|
try testing.expectEqualSlices(u8, target.wire(), (try record.rdataCname(p.bytes, cname)).wire());
|
||||||
|
|
||||||
|
const a = (try it.next()).?;
|
||||||
|
try testing.expectEqual(types.Type.a, a.rtype);
|
||||||
|
try testing.expectEqualSlices(u8, target.wire(), a.name.wire());
|
||||||
|
try testing.expectEqual(upstream_rdata, try record.rdataA(p.bytes, a));
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(u64, 1), h.stats.safesearch_rewrites.load(.monotonic));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// case 8: rate limit
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test "S7 case 8: the third query inside the window is refused" {
|
||||||
|
if (!build_options.integration) return error.SkipZigTest;
|
||||||
|
|
||||||
|
const gpa = testing.allocator;
|
||||||
|
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var limiter: rate_limiter.RateLimiter = try .init(gpa, .{ .limit = 2, .window_seconds = 60 });
|
||||||
|
defer limiter.deinit();
|
||||||
|
|
||||||
|
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||||
|
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||||
|
|
||||||
|
var fake: FakeUpstream = .{ .reply = .a };
|
||||||
|
var h = baseHandler(fake.client());
|
||||||
|
h.limiter = &limiter;
|
||||||
|
h.logger = ≶
|
||||||
|
|
||||||
|
var loop = try Loop.bind(gpa, io, &h);
|
||||||
|
defer loop.stop(gpa, io);
|
||||||
|
try loop.start(io);
|
||||||
|
|
||||||
|
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||||
|
for ([_]u16{ 0x8801, 0x8802 }) |id| {
|
||||||
|
var query_buf: [512]u8 = undefined;
|
||||||
|
const reply = try loop.ask(io, queryFor(&query_buf, id, "example.com", .a), &reply_buf);
|
||||||
|
try testing.expectEqual(types.Rcode.no_error, (try packet.parse(reply)).header.flags.rcode);
|
||||||
|
}
|
||||||
|
|
||||||
|
var third_buf: [512]u8 = undefined;
|
||||||
|
const refused = try loop.ask(io, queryFor(&third_buf, 0x8803, "example.com", .a), &reply_buf);
|
||||||
|
const p = try packet.parse(refused);
|
||||||
|
try testing.expectEqual(types.Rcode.refused, p.header.flags.rcode);
|
||||||
|
try testing.expectEqual(@as(u16, 0x8803), p.header.id);
|
||||||
|
try testing.expectEqual(@as(u64, 1), h.stats.refused.load(.monotonic));
|
||||||
|
try testing.expectEqual(@as(u64, 2), fake.calls.load(.monotonic));
|
||||||
|
|
||||||
|
// Ruling 8: a refused query is never query-logged.
|
||||||
|
var entries: [log_queue_len]logger_mod.Entry = undefined;
|
||||||
|
try testing.expectEqual(@as(usize, 2), drainLog(&lg, io, &entries).len);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// case 9: pause
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test "S7 case 9: pause lifts filtering and unpause restores it" {
|
||||||
|
if (!build_options.integration) return error.SkipZigTest;
|
||||||
|
|
||||||
|
const gpa = testing.allocator;
|
||||||
|
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var snapshot = try buildSnapshot(gpa, .{ .rules = &.{blockRule("ads.example.com")} });
|
||||||
|
defer snapshot.deinit();
|
||||||
|
var mgr: manager.Manager = undefined;
|
||||||
|
fixtureManager(&mgr, &snapshot);
|
||||||
|
|
||||||
|
var paused: pause.Pause = .{};
|
||||||
|
paused.pauseFor(std.Io.Clock.real.now(io).toSeconds(), null);
|
||||||
|
|
||||||
|
var fake: FakeUpstream = .{ .reply = .a };
|
||||||
|
var h = baseHandler(fake.client());
|
||||||
|
h.manager = &mgr;
|
||||||
|
h.pause = &paused;
|
||||||
|
|
||||||
|
var loop = try Loop.bind(gpa, io, &h);
|
||||||
|
defer loop.stop(gpa, io);
|
||||||
|
try loop.start(io);
|
||||||
|
|
||||||
|
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||||
|
|
||||||
|
var paused_buf: [512]u8 = undefined;
|
||||||
|
const while_paused = try loop.ask(io, queryFor(&paused_buf, 0x9901, "ads.example.com", .a), &reply_buf);
|
||||||
|
const p = try packet.parse(while_paused);
|
||||||
|
try testing.expectEqual(upstream_rdata, try record.rdataA(p.bytes, try firstAnswer(p)));
|
||||||
|
try testing.expectEqual(@as(u64, 1), h.stats.paused_queries.load(.monotonic));
|
||||||
|
try testing.expectEqual(@as(u64, 0), h.stats.blocked.load(.monotonic));
|
||||||
|
|
||||||
|
// Resuming puts the block back without a restart (ruling 18).
|
||||||
|
paused.unpause();
|
||||||
|
|
||||||
|
var resumed_buf: [512]u8 = undefined;
|
||||||
|
const after = try loop.ask(io, queryFor(&resumed_buf, 0x9902, "ads.example.com", .a), &reply_buf);
|
||||||
|
const after_p = try packet.parse(after);
|
||||||
|
try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, try record.rdataA(after_p.bytes, try firstAnswer(after_p)));
|
||||||
|
try testing.expectEqual(@as(u64, 1), h.stats.blocked.load(.monotonic));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// case 10: client tracking
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test "S7 case 10: the querying client is materialised as a row" {
|
||||||
|
if (!build_options.integration) return error.SkipZigTest;
|
||||||
|
|
||||||
|
const gpa = testing.allocator;
|
||||||
|
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
// The tracker's own connection (ruling 21), in memory here: the flush is
|
||||||
|
// what this case asserts on, not where the file lives.
|
||||||
|
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||||
|
defer database.close();
|
||||||
|
try db.applyPragmas(&database, .{});
|
||||||
|
_ = try migrations.migrate(&database);
|
||||||
|
|
||||||
|
var tracker: clients.Tracker = .init(30);
|
||||||
|
|
||||||
|
var fake: FakeUpstream = .{ .reply = .a };
|
||||||
|
var h = baseHandler(fake.client());
|
||||||
|
h.tracker = &tracker;
|
||||||
|
|
||||||
|
var loop = try Loop.bind(gpa, io, &h);
|
||||||
|
defer loop.stop(gpa, io);
|
||||||
|
try loop.start(io);
|
||||||
|
|
||||||
|
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||||
|
for ([_]u16{ 0xa001, 0xa002 }) |id| {
|
||||||
|
var query_buf: [512]u8 = undefined;
|
||||||
|
_ = try loop.ask(io, queryFor(&query_buf, id, "example.com", .a), &reply_buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two queries from one client are one pending entry, and the forced pass
|
||||||
|
// stands in for the 60-second flush interval (S4 As-built seam).
|
||||||
|
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
|
||||||
|
tracker.flushOnce(io, &database, true);
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
|
||||||
|
try testing.expectEqual(@as(u32, 0), tracker.pendingClients(io));
|
||||||
|
try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).flushed);
|
||||||
|
|
||||||
|
var stmt = try database.prepare("SELECT ip, hand_edited FROM clients");
|
||||||
|
defer stmt.deinit();
|
||||||
|
try testing.expect(try stmt.step());
|
||||||
|
try testing.expectEqualStrings("127.0.0.1", stmt.columnText(0));
|
||||||
|
try testing.expectEqual(@as(i64, 0), stmt.columnInt(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// case 11: the whole application
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `std.testing.tmpDir` creates its directory against `std.testing.io`, so the
|
||||||
|
/// application under test runs on the same `Io` instance the fixture used. The
|
||||||
|
/// other cases build an `Io.Threaded` of their own, the way the listener tests
|
||||||
|
/// do; this one cannot, because the temporary directory is already bound to
|
||||||
|
/// this instance.
|
||||||
|
const test_io = testing.io;
|
||||||
|
|
||||||
|
/// Where `std.testing.tmpDir` puts its directories (`lib/std/testing.zig:634`).
|
||||||
|
const tmp_prefix = ".zig-cache/tmp/";
|
||||||
|
const sub_path_len = @typeInfo(@FieldType(testing.TmpDir, "sub_path")).array.len;
|
||||||
|
|
||||||
|
/// High enough to need no privilege, and not the 15353/15354 pair the milestone
|
||||||
|
/// smoke test used, so a stray smoke process cannot make this case pass.
|
||||||
|
const app_port = 15455;
|
||||||
|
|
||||||
|
/// The unreachable upstream the seed configuration names. Nothing in this case
|
||||||
|
/// needs it: the query it resolves is a local record, and a dead upstream is
|
||||||
|
/// what proves the fail-open design still serves.
|
||||||
|
const dead_upstream = "https://127.0.0.1:9/dns-query";
|
||||||
|
|
||||||
|
const app_config =
|
||||||
|
\\.{
|
||||||
|
\\ .dns = .{
|
||||||
|
\\ .bind_ipv4 = "127.0.0.1",
|
||||||
|
\\ .bind_ipv6 = "::1",
|
||||||
|
\\ .port = 15455,
|
||||||
|
\\ .rate_limit = 1000,
|
||||||
|
\\ .rate_window_seconds = 60,
|
||||||
|
\\ },
|
||||||
|
\\ .logging = .{ .level = .info, .output = .stderr },
|
||||||
|
\\ .web = .{ .enabled = false },
|
||||||
|
\\ .groups = .{ .{ .name = "default" } },
|
||||||
|
\\ .upstreams = .{ .{ .url = "https://127.0.0.1:9/dns-query" } },
|
||||||
|
\\ .local_records = .{
|
||||||
|
\\ .{ .name = "boot.test", .rtype = .a, .value = "10.9.8.7", .ttl = 60 },
|
||||||
|
\\ },
|
||||||
|
\\}
|
||||||
|
\\
|
||||||
|
;
|
||||||
|
|
||||||
|
comptime {
|
||||||
|
// The port and the upstream appear in the configuration text as literals,
|
||||||
|
// because a `.zon` file is data and not a format string.
|
||||||
|
std.debug.assert(std.mem.containsAtLeast(u8, app_config, 1, std.fmt.comptimePrint("{d}", .{app_port})));
|
||||||
|
std.debug.assert(std.mem.containsAtLeast(u8, app_config, 1, dead_upstream));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long one attempt at reaching the booting server waits, and how many
|
||||||
|
/// attempts it gets. The product is the time the application has to bind.
|
||||||
|
const boot_attempt: std.Io.Timeout = .{ .duration = .{ .raw = .fromMilliseconds(200), .clock = .awake } };
|
||||||
|
const boot_attempts = 100;
|
||||||
|
|
||||||
|
/// Queries the booting server until it answers. A server that has not bound yet
|
||||||
|
/// either swallows the datagram or answers it with an ICMP rejection, and both
|
||||||
|
/// arrive here as an error worth retrying.
|
||||||
|
fn askUntilAnswered(
|
||||||
|
socket: *const net.Socket,
|
||||||
|
dest: net.IpAddress,
|
||||||
|
query: []const u8,
|
||||||
|
buf: []u8,
|
||||||
|
) ![]u8 {
|
||||||
|
var attempt: usize = 0;
|
||||||
|
while (attempt < boot_attempts) : (attempt += 1) {
|
||||||
|
socket.send(test_io, &dest, query) catch continue;
|
||||||
|
const msg = socket.receiveTimeout(test_io, buf, boot_attempt) catch continue;
|
||||||
|
return msg.data;
|
||||||
|
}
|
||||||
|
return error.TestAppNeverAnswered;
|
||||||
|
}
|
||||||
|
|
||||||
|
test "S7 case 11: the app boots, serves a query and exits zero on shutdown" {
|
||||||
|
if (!build_options.integration) return error.SkipZigTest;
|
||||||
|
|
||||||
|
const gpa = testing.allocator;
|
||||||
|
|
||||||
|
var tmp = testing.tmpDir(.{ .iterate = true });
|
||||||
|
defer tmp.cleanup();
|
||||||
|
try tmp.dir.writeFile(test_io, .{ .sub_path = "config.zon", .data = app_config });
|
||||||
|
|
||||||
|
var root_buf: [tmp_prefix.len + sub_path_len]u8 = undefined;
|
||||||
|
@memcpy(root_buf[0..tmp_prefix.len], tmp_prefix);
|
||||||
|
@memcpy(root_buf[tmp_prefix.len..], &tmp.sub_path);
|
||||||
|
const root: []const u8 = &root_buf;
|
||||||
|
|
||||||
|
var config_buf: [root_buf.len + "/config.zon".len]u8 = undefined;
|
||||||
|
const config_path = try std.fmt.bufPrint(&config_buf, "{s}/config.zon", .{root});
|
||||||
|
|
||||||
|
var out: std.Io.Writer.Allocating = .init(gpa);
|
||||||
|
defer out.deinit();
|
||||||
|
var err: std.Io.Writer.Allocating = .init(gpa);
|
||||||
|
defer err.deinit();
|
||||||
|
|
||||||
|
const runner: cli.Runner = .{
|
||||||
|
.io = test_io,
|
||||||
|
.gpa = gpa,
|
||||||
|
.out = &out.writer,
|
||||||
|
.err = &err.writer,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The shutdown event is process-global, and another case in this binary may
|
||||||
|
// have left it set.
|
||||||
|
shutdown.reset();
|
||||||
|
defer shutdown.reset();
|
||||||
|
|
||||||
|
var future = try test_io.concurrent(app.run, .{ runner, cli.Paths{
|
||||||
|
.data_dir = root,
|
||||||
|
.config = config_path,
|
||||||
|
} });
|
||||||
|
|
||||||
|
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||||
|
const client = try client_address.bind(test_io, .{ .mode = .dgram });
|
||||||
|
defer client.close(test_io);
|
||||||
|
|
||||||
|
const server_address: net.IpAddress = try .parse("127.0.0.1", app_port);
|
||||||
|
|
||||||
|
var query_buf: [512]u8 = undefined;
|
||||||
|
const query = queryFor(&query_buf, 0xb001, "boot.test", .a);
|
||||||
|
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||||
|
|
||||||
|
const reply = askUntilAnswered(&client, server_address, query, &reply_buf) catch |e| {
|
||||||
|
shutdown.trigger(test_io);
|
||||||
|
_ = future.await(test_io);
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
|
||||||
|
const p = try packet.parse(reply);
|
||||||
|
try testing.expectEqual(@as(u16, 0xb001), p.header.id);
|
||||||
|
try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
|
||||||
|
try testing.expectEqual(true, p.header.flags.aa);
|
||||||
|
try testing.expectEqual([4]u8{ 10, 9, 8, 7 }, try record.rdataA(p.bytes, try firstAnswer(p)));
|
||||||
|
|
||||||
|
shutdown.trigger(test_io);
|
||||||
|
try testing.expectEqual(cli.exit_ok, future.await(test_io));
|
||||||
|
|
||||||
|
// The lifecycle proof is the exit code, and a clean exit prints nothing.
|
||||||
|
try testing.expectEqualStrings("", err.written());
|
||||||
|
}
|
||||||
@@ -18,6 +18,10 @@ const net = std.Io.net;
|
|||||||
const handler = @import("handler.zig");
|
const handler = @import("handler.zig");
|
||||||
const tcp_server = @import("tcp_server.zig");
|
const tcp_server = @import("tcp_server.zig");
|
||||||
const udp_server = @import("udp_server.zig");
|
const udp_server = @import("udp_server.zig");
|
||||||
|
const model = @import("../config/model.zig");
|
||||||
|
const response = @import("../filter/response.zig");
|
||||||
|
const forward_zones = @import("../local/forward_zones.zig");
|
||||||
|
const records = @import("../local/records.zig");
|
||||||
const packet = @import("../dns/packet.zig");
|
const packet = @import("../dns/packet.zig");
|
||||||
const record = @import("../dns/record.zig");
|
const record = @import("../dns/record.zig");
|
||||||
const types = @import("../dns/types.zig");
|
const types = @import("../dns/types.zig");
|
||||||
@@ -27,6 +31,31 @@ const transport = @import("../upstream/transport.zig");
|
|||||||
|
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
||||||
|
const empty_records: records.Records = .empty;
|
||||||
|
const empty_zones: forward_zones.Zones = .empty;
|
||||||
|
const blocking_defaults: model.Blocking = .{};
|
||||||
|
const blocking: response.Options = .{
|
||||||
|
.mode = blocking_defaults.response,
|
||||||
|
.ttl = blocking_defaults.ttl,
|
||||||
|
};
|
||||||
|
const forward_timeout: std.Io.Clock.Duration = .{
|
||||||
|
.raw = model.readTimeout(.{}),
|
||||||
|
.clock = .awake,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// An upstream and nothing else optional: no filtering, no cache, no log. The
|
||||||
|
/// listeners and the pool are what this test exercises, so the handler is the
|
||||||
|
/// same bare one its own tests use.
|
||||||
|
fn bareHandler(client: transport.Client) handler.Handler {
|
||||||
|
return .{
|
||||||
|
.upstream = client,
|
||||||
|
.blocking = blocking,
|
||||||
|
.forward_read_timeout = forward_timeout,
|
||||||
|
.records = &empty_records,
|
||||||
|
.zones = &empty_zones,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Long enough that a loopback round trip cannot lose to scheduling, short
|
/// Long enough that a loopback round trip cannot lose to scheduling, short
|
||||||
/// enough that a broken server fails the run instead of hanging it.
|
/// enough that a broken server fails the run instead of hanging it.
|
||||||
const budget: std.Io.Timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } };
|
const budget: std.Io.Timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } };
|
||||||
@@ -226,7 +255,7 @@ test "the whole resolver answers over udp and tcp and fails over to a healthy up
|
|||||||
};
|
};
|
||||||
var upstreams: pool.Pool = .init(&entries, test_cfg, attempt_timeout, 1);
|
var upstreams: pool.Pool = .init(&entries, test_cfg, attempt_timeout, 1);
|
||||||
|
|
||||||
var h: handler.Handler = .{ .upstream = upstreams.client() };
|
var h = bareHandler(upstreams.client());
|
||||||
|
|
||||||
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 udp = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
var udp = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//! SIGINT and SIGTERM, turned into one `std.Io.Event`.
|
||||||
|
//!
|
||||||
|
//! No signalfd, no self-pipe, no epoll: the handler does exactly one thing, and
|
||||||
|
//! `std.Io.Event.set` is async-signal-safe on the Threaded Linux backend — a
|
||||||
|
//! raw `futex` wake with no allocation and no lock (`Io.zig:1855` →
|
||||||
|
//! `Threaded.futexWake`). The `.mask`/`.flags` shape is the one Threaded uses
|
||||||
|
//! for its own `SIG.IO`/`SIG.PIPE` handlers (`Threaded.zig:1653`): an empty
|
||||||
|
//! mask and no `SA_RESTART`, so a blocking syscall returns `EINTR` and the
|
||||||
|
//! backend's retry loop re-reads the cancellation state.
|
||||||
|
//!
|
||||||
|
//! Everything a shutdown actually has to do — drain the query log, cancel the
|
||||||
|
//! task group, close the databases — happens on the task blocked in `wait`.
|
||||||
|
//!
|
||||||
|
//! The previous handlers are not restored. The process is leaving, and a
|
||||||
|
//! second SIGTERM during teardown should still terminate it the default way
|
||||||
|
//! only if the operator sends it before this module is armed.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const posix = std.posix;
|
||||||
|
|
||||||
|
var event: std.Io.Event = .unset;
|
||||||
|
|
||||||
|
/// Read by the signal handler, written by `install` before the handler exists.
|
||||||
|
/// A `std.Io` is two pointers and cannot be stored atomically, so ordering is
|
||||||
|
/// what makes the read safe: the store precedes the `sigaction` syscall that
|
||||||
|
/// arms the handler, and no signal can reach the handler before that call
|
||||||
|
/// returns.
|
||||||
|
var handler_io: ?std.Io = null;
|
||||||
|
|
||||||
|
var installed: bool = false;
|
||||||
|
|
||||||
|
/// Arms the handlers for INT and TERM. Calling it again is a no-op: the process
|
||||||
|
/// has one event and one pair of handlers, and a second boot inside one process
|
||||||
|
/// (which only a test does) must not re-arm anything.
|
||||||
|
pub fn install(io: std.Io) void {
|
||||||
|
if (installed) return;
|
||||||
|
handler_io = io;
|
||||||
|
installed = true;
|
||||||
|
|
||||||
|
const act: posix.Sigaction = .{
|
||||||
|
.handler = .{ .handler = onSignal },
|
||||||
|
.mask = posix.sigemptyset(),
|
||||||
|
.flags = 0,
|
||||||
|
};
|
||||||
|
posix.sigaction(.INT, &act, null);
|
||||||
|
posix.sigaction(.TERM, &act, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn onSignal(_: posix.SIG) callconv(.c) void {
|
||||||
|
const io = handler_io orelse return;
|
||||||
|
event.set(io);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocks until a shutdown is requested. A canceled wait is the caller's cue to
|
||||||
|
/// tear down as well, which is why `app.run` treats both results the same.
|
||||||
|
pub fn wait(io: std.Io) std.Io.Cancelable!void {
|
||||||
|
return event.wait(io);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The programmatic equivalent of the signal: what a test uses to shut the app
|
||||||
|
/// down, and what a Phase 8 restart endpoint would call.
|
||||||
|
pub fn trigger(io: std.Io) void {
|
||||||
|
event.set(io);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn isRequested() bool {
|
||||||
|
return event.isSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the request so the next `wait` blocks again. Only a test that boots
|
||||||
|
/// the app more than once in one process needs this; a served process shuts
|
||||||
|
/// down once.
|
||||||
|
pub fn reset() void {
|
||||||
|
event.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
const testing = std.testing;
|
||||||
|
|
||||||
|
test "trigger releases a waiter and isRequested reports it" {
|
||||||
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
reset();
|
||||||
|
try testing.expect(!isRequested());
|
||||||
|
|
||||||
|
trigger(io);
|
||||||
|
try testing.expect(isRequested());
|
||||||
|
// Already set, so this returns without blocking.
|
||||||
|
try wait(io);
|
||||||
|
|
||||||
|
reset();
|
||||||
|
try testing.expect(!isRequested());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "a waiting task is released by a later trigger" {
|
||||||
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
reset();
|
||||||
|
|
||||||
|
var group: std.Io.Group = .init;
|
||||||
|
try group.concurrent(io, waitThenSet, .{ io, &done });
|
||||||
|
trigger(io);
|
||||||
|
try group.await(io);
|
||||||
|
|
||||||
|
try testing.expect(done.isSet());
|
||||||
|
reset();
|
||||||
|
done.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
var done: std.Io.Event = .unset;
|
||||||
|
|
||||||
|
fn waitThenSet(io: std.Io, flag: *std.Io.Event) std.Io.Cancelable!void {
|
||||||
|
try wait(io);
|
||||||
|
flag.set(io);
|
||||||
|
}
|
||||||
+75
-12
@@ -12,8 +12,25 @@
|
|||||||
//! 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 `std.Io.Select` and
|
||||||
//! the loser is canceled.
|
//! 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 handler = @import("handler.zig");
|
const handler = @import("handler.zig");
|
||||||
const transport = @import("../upstream/transport.zig");
|
const transport = @import("../upstream/transport.zig");
|
||||||
|
|
||||||
@@ -53,6 +70,16 @@ const State = enum(u32) { idle, serving, closing };
|
|||||||
/// before the close, and `deinit` only touches `.active` slots.
|
/// before the close, and `deinit` only touches `.active` slots.
|
||||||
const ConnState = enum { free, active, closing };
|
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.
|
/// What the accept loop does with a stream it has just accepted.
|
||||||
const Claim = union(enum) {
|
const Claim = union(enum) {
|
||||||
/// The stream owns `conns[index]`.
|
/// The stream owns `conns[index]`.
|
||||||
@@ -77,7 +104,7 @@ pub const TcpServer = struct {
|
|||||||
state: std.atomic.Value(State),
|
state: std.atomic.Value(State),
|
||||||
stopped: std.Io.Event,
|
stopped: std.Io.Event,
|
||||||
|
|
||||||
/// One slot is ~131 KiB, so the default 64 connections cost ~8.4 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.
|
||||||
@@ -86,7 +113,15 @@ pub const TcpServer = struct {
|
|||||||
reply: [transport.max_message_len]u8,
|
reply: [transport.max_message_len]u8,
|
||||||
read_buf: [stream_buffer_len]u8,
|
read_buf: [stream_buffer_len]u8,
|
||||||
write_buf: [stream_buffer_len]u8,
|
write_buf: [stream_buffer_len]u8,
|
||||||
|
/// The handler's per-query working memory. It belongs to the slot so
|
||||||
|
/// that answering a message allocates nothing, and a connection is
|
||||||
|
/// answered serially, so one query uses it at a time.
|
||||||
|
scratch: handler.Scratch,
|
||||||
stream: std.Io.net.Stream,
|
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`.
|
/// Guarded by `TcpServer.mutex`.
|
||||||
state: ConnState,
|
state: ConnState,
|
||||||
};
|
};
|
||||||
@@ -96,7 +131,7 @@ pub const TcpServer = struct {
|
|||||||
pub fn listen(
|
pub fn listen(
|
||||||
gpa: std.mem.Allocator,
|
gpa: std.mem.Allocator,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
address: std.Io.net.IpAddress,
|
listen_address: std.Io.net.IpAddress,
|
||||||
h: *handler.Handler,
|
h: *handler.Handler,
|
||||||
options: Options,
|
options: Options,
|
||||||
) ListenError!TcpServer {
|
) ListenError!TcpServer {
|
||||||
@@ -106,7 +141,7 @@ pub const TcpServer = struct {
|
|||||||
errdefer gpa.free(conns);
|
errdefer gpa.free(conns);
|
||||||
for (conns) |*conn| conn.state = .free;
|
for (conns) |*conn| conn.state = .free;
|
||||||
|
|
||||||
const local = address;
|
const local = listen_address;
|
||||||
const server = try local.listen(io, .{ .reuse_address = true });
|
const server = try local.listen(io, .{ .reuse_address = true });
|
||||||
|
|
||||||
return .{
|
return .{
|
||||||
@@ -132,15 +167,28 @@ pub const TcpServer = struct {
|
|||||||
if (self.state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return;
|
if (self.state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return;
|
||||||
|
|
||||||
var group: std.Io.Group = .init;
|
var group: std.Io.Group = .init;
|
||||||
self.acceptLoop(io, &group);
|
switch (self.acceptLoop(io, &group)) {
|
||||||
|
// `deinit` shut every live connection down before it published
|
||||||
// A reply that is half written is worse than no reply, so the live
|
// `.closing`, so each one is already unblocked and ending on its
|
||||||
// connections are awaited even when this task is being canceled.
|
// 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);
|
const prev = io.swapCancelProtection(.blocked);
|
||||||
group.await(io) catch |err| switch (err) {
|
group.await(io) catch |err| switch (err) {
|
||||||
error.Canceled => unreachable,
|
error.Canceled => unreachable,
|
||||||
};
|
};
|
||||||
_ = io.swapCancelProtection(prev);
|
_ = 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);
|
self.stopped.set(io);
|
||||||
}
|
}
|
||||||
@@ -167,14 +215,17 @@ pub const TcpServer = struct {
|
|||||||
self.* = undefined;
|
self.* = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn acceptLoop(self: *TcpServer, io: std.Io, group: *std.Io.Group) void {
|
fn acceptLoop(self: *TcpServer, io: std.Io, group: *std.Io.Group) Stop {
|
||||||
while (self.state.load(.acquire) == .serving) {
|
while (self.state.load(.acquire) == .serving) {
|
||||||
const stream = self.server.accept(io) catch |err| switch (err) {
|
const stream = self.server.accept(io) catch |err| switch (err) {
|
||||||
error.Canceled, error.SocketNotListening => return,
|
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 => {
|
else => {
|
||||||
bump(&self.stats.accept_errors);
|
bump(&self.stats.accept_errors);
|
||||||
log.debug("tcp accept failed: {t}", .{err});
|
log.debug("tcp accept failed: {t}", .{err});
|
||||||
retry_delay.sleep(io) catch return;
|
retry_delay.sleep(io) catch return .canceled;
|
||||||
continue;
|
continue;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -192,7 +243,7 @@ pub const TcpServer = struct {
|
|||||||
.shutting_down => {
|
.shutting_down => {
|
||||||
bump(&self.stats.rejected_at_shutdown);
|
bump(&self.stats.rejected_at_shutdown);
|
||||||
stream.close(io);
|
stream.close(io);
|
||||||
return;
|
return .closing;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -206,6 +257,9 @@ pub const TcpServer = struct {
|
|||||||
|
|
||||||
bump(&self.stats.accepted);
|
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 {
|
fn serveConn(self: *TcpServer, io: std.Io, index: usize) void {
|
||||||
@@ -258,7 +312,15 @@ pub const TcpServer = struct {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytes = switch (self.handler.handle(io, .tcp, conn.query[0..len], &conn.reply)) {
|
const outcome = self.handler.handle(
|
||||||
|
io,
|
||||||
|
.tcp,
|
||||||
|
address.NetAddress.fromIp(conn.peer),
|
||||||
|
conn.query[0..len],
|
||||||
|
&conn.reply,
|
||||||
|
&conn.scratch,
|
||||||
|
);
|
||||||
|
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.
|
||||||
.drop => return,
|
.drop => return,
|
||||||
.reply => |b| b,
|
.reply => |b| b,
|
||||||
@@ -286,6 +348,7 @@ pub const TcpServer = struct {
|
|||||||
switch (outcome) {
|
switch (outcome) {
|
||||||
.slot => |index| {
|
.slot => |index| {
|
||||||
self.conns[index].stream = stream;
|
self.conns[index].stream = stream;
|
||||||
|
self.conns[index].peer = stream.socket.address;
|
||||||
self.conns[index].state = .active;
|
self.conns[index].state = .active;
|
||||||
},
|
},
|
||||||
.at_capacity, .shutting_down => {},
|
.at_capacity, .shutting_down => {},
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const net = std.Io.net;
|
|||||||
|
|
||||||
const handler = @import("handler.zig");
|
const handler = @import("handler.zig");
|
||||||
const tcp_server = @import("tcp_server.zig");
|
const tcp_server = @import("tcp_server.zig");
|
||||||
|
const model = @import("../config/model.zig");
|
||||||
|
const response = @import("../filter/response.zig");
|
||||||
|
const forward_zones = @import("../local/forward_zones.zig");
|
||||||
|
const records = @import("../local/records.zig");
|
||||||
const header = @import("../dns/header.zig");
|
const header = @import("../dns/header.zig");
|
||||||
const packet = @import("../dns/packet.zig");
|
const packet = @import("../dns/packet.zig");
|
||||||
const types = @import("../dns/types.zig");
|
const types = @import("../dns/types.zig");
|
||||||
@@ -22,6 +26,31 @@ const transport = @import("../upstream/transport.zig");
|
|||||||
|
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
||||||
|
const empty_records: records.Records = .empty;
|
||||||
|
const empty_zones: forward_zones.Zones = .empty;
|
||||||
|
const blocking_defaults: model.Blocking = .{};
|
||||||
|
const blocking: response.Options = .{
|
||||||
|
.mode = blocking_defaults.response,
|
||||||
|
.ttl = blocking_defaults.ttl,
|
||||||
|
};
|
||||||
|
const forward_timeout: std.Io.Clock.Duration = .{
|
||||||
|
.raw = model.readTimeout(.{}),
|
||||||
|
.clock = .awake,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// An upstream and nothing else optional: no filtering, no cache, no log. The
|
||||||
|
/// listener is what these tests exercise, so the handler is the same bare one
|
||||||
|
/// its own tests use.
|
||||||
|
fn bareHandler(client: transport.Client) handler.Handler {
|
||||||
|
return .{
|
||||||
|
.upstream = client,
|
||||||
|
.blocking = blocking,
|
||||||
|
.forward_read_timeout = forward_timeout,
|
||||||
|
.records = &empty_records,
|
||||||
|
.zones = &empty_zones,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(5), .clock = .awake };
|
const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(5), .clock = .awake };
|
||||||
|
|
||||||
/// Short enough to keep the idle-timeout test quick, long enough that a
|
/// Short enough to keep the idle-timeout test quick, long enough that a
|
||||||
@@ -150,7 +179,7 @@ test "two length-prefixed queries share one connection" {
|
|||||||
const io = threaded.io();
|
const io = threaded.io();
|
||||||
|
|
||||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
var h = bareHandler(fake.client());
|
||||||
|
|
||||||
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 server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
|
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
|
||||||
@@ -171,6 +200,145 @@ test "two length-prefixed queries share one connection" {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "the claimed slot records the connecting client" {
|
||||||
|
if (!build_options.integration) return error.SkipZigTest;
|
||||||
|
|
||||||
|
const gpa = testing.allocator;
|
||||||
|
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||||
|
var h = bareHandler(fake.client());
|
||||||
|
|
||||||
|
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||||
|
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
|
||||||
|
const server_address = server.boundAddress();
|
||||||
|
|
||||||
|
var group: std.Io.Group = .init;
|
||||||
|
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
|
||||||
|
|
||||||
|
try bounded(io, twoQueriesOnOneConnection, .{ io, server_address });
|
||||||
|
|
||||||
|
// The only connection took slot 0, and the reply the client already read
|
||||||
|
// 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
|
||||||
|
// client under whatever the uninitialized slot happened to hold.
|
||||||
|
const peer = server.conns[0].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.expect(peer.ip4.port != 0);
|
||||||
|
|
||||||
|
server.deinit(gpa, io);
|
||||||
|
group.await(io) catch |err| switch (err) {
|
||||||
|
error.Canceled => unreachable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `std.Io.Group` takes only `Cancelable!void`, so the result is reported out
|
||||||
|
/// of band. `answered` is set either way — a client that fails early must not
|
||||||
|
/// leave the test blocked waiting for a reply that will never come, and `set`
|
||||||
|
/// is documented to have no effect the second time.
|
||||||
|
fn holdConnection(
|
||||||
|
io: std.Io,
|
||||||
|
address: net.IpAddress,
|
||||||
|
answered: *std.Io.Event,
|
||||||
|
result: *anyerror!void,
|
||||||
|
) void {
|
||||||
|
result.* = holdConnectionOpen(io, address, answered);
|
||||||
|
answered.set(io);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds a connection open the way RFC 7766 lets a real client hold one: one
|
||||||
|
/// query answered, then nothing, so the server sits blocked on the read for the
|
||||||
|
/// next message. Returns once the server ends the connection, however it ends
|
||||||
|
/// it — a canceled server closes the socket, which the client sees either as
|
||||||
|
/// end of stream or as a reset.
|
||||||
|
fn holdConnectionOpen(io: std.Io, address: net.IpAddress, answered: *std.Io.Event) anyerror!void {
|
||||||
|
const remote = address;
|
||||||
|
var stream = try remote.connect(io, .{ .mode = .stream });
|
||||||
|
defer stream.close(io);
|
||||||
|
|
||||||
|
var read_buf: [1024]u8 = undefined;
|
||||||
|
var write_buf: [1024]u8 = undefined;
|
||||||
|
var reader = stream.reader(io, &read_buf);
|
||||||
|
var writer = stream.writer(io, &write_buf);
|
||||||
|
|
||||||
|
try writer.interface.writeAll(&transport.framePrefix(@intCast(query_bytes.len)));
|
||||||
|
try writer.interface.writeAll(query_bytes);
|
||||||
|
try writer.interface.flush();
|
||||||
|
|
||||||
|
const len = transport.parsePrefix((try reader.interface.takeArray(transport.prefix_len)).*);
|
||||||
|
try expectAnswersQuery(try reader.interface.take(len));
|
||||||
|
|
||||||
|
answered.set(io);
|
||||||
|
|
||||||
|
var sink: [64]u8 = undefined;
|
||||||
|
_ = reader.interface.readSliceShort(&sink) catch {};
|
||||||
|
}
|
||||||
|
|
||||||
|
test "a canceled serve does not wait for a live connection" {
|
||||||
|
if (!build_options.integration) return error.SkipZigTest;
|
||||||
|
|
||||||
|
const gpa = testing.allocator;
|
||||||
|
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||||
|
defer threaded.deinit();
|
||||||
|
const io = threaded.io();
|
||||||
|
|
||||||
|
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||||
|
var h = bareHandler(fake.client());
|
||||||
|
|
||||||
|
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||||
|
// The idle budget is the whole time a drain would have to wait out, so it
|
||||||
|
// is set far beyond any patience this test run has: if `serve` waits for
|
||||||
|
// the connection instead of canceling it, the wait never ends.
|
||||||
|
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
|
||||||
|
.max_connections = 2,
|
||||||
|
.idle_timeout = .{ .raw = .fromSeconds(600), .clock = .awake },
|
||||||
|
});
|
||||||
|
const server_address = server.boundAddress();
|
||||||
|
|
||||||
|
var serving: std.Io.Group = .init;
|
||||||
|
try serving.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
|
||||||
|
|
||||||
|
var answered: std.Io.Event = .unset;
|
||||||
|
var client_result: anyerror!void = {};
|
||||||
|
var client_group: std.Io.Group = .init;
|
||||||
|
try client_group.concurrent(io, holdConnection, .{ io, server_address, &answered, &client_result });
|
||||||
|
|
||||||
|
// The reply proves the connection is claimed and served, so the connection
|
||||||
|
// task is now blocked reading the message that never comes. That is the
|
||||||
|
// state a drain hangs in.
|
||||||
|
answered.waitUncancelable(io);
|
||||||
|
|
||||||
|
// This is the composition root's shutdown: cancel the task group before
|
||||||
|
// anything it borrows is released, so no `deinit` has shut the connection
|
||||||
|
// down. It must still return. A regression here does not fail the test, it
|
||||||
|
// hangs the run — there is no way to bound a join that does not finish.
|
||||||
|
serving.cancel(io);
|
||||||
|
|
||||||
|
// Cancellation must not skip the per-connection cleanup: the slot is
|
||||||
|
// released and the socket closed by `serveConn`'s defer, which runs on the
|
||||||
|
// canceled path like any other.
|
||||||
|
try testing.expectEqual(@as(?usize, 0), firstFreeSlot(&server));
|
||||||
|
|
||||||
|
client_group.cancel(io);
|
||||||
|
server.deinit(gpa, io);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
try client_result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first slot the server would hand out, read after `serve` has returned so
|
||||||
|
/// nothing can be writing it.
|
||||||
|
fn firstFreeSlot(server: *const tcp_server.TcpServer) ?usize {
|
||||||
|
for (server.conns, 0..) |*conn, index| {
|
||||||
|
if (conn.state == .free) return index;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
test "an idle connection is closed and counted" {
|
test "an idle connection is closed and counted" {
|
||||||
if (!build_options.integration) return error.SkipZigTest;
|
if (!build_options.integration) return error.SkipZigTest;
|
||||||
|
|
||||||
@@ -180,7 +348,7 @@ test "an idle connection is closed and counted" {
|
|||||||
const io = threaded.io();
|
const io = threaded.io();
|
||||||
|
|
||||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
var h = bareHandler(fake.client());
|
||||||
|
|
||||||
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 server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
|
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
|
||||||
@@ -213,7 +381,7 @@ test "a zero-length message is a connection error" {
|
|||||||
const io = threaded.io();
|
const io = threaded.io();
|
||||||
|
|
||||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
var h = bareHandler(fake.client());
|
||||||
|
|
||||||
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 server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
|
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
|
||||||
@@ -265,7 +433,7 @@ test "deinit ends a serve loop that is blocked on accept" {
|
|||||||
const io = threaded.io();
|
const io = threaded.io();
|
||||||
|
|
||||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
var h = bareHandler(fake.client());
|
||||||
|
|
||||||
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 server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
|
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
//! allocates nothing. The pool is sized once in `bind` and never grows.
|
//! allocates nothing. The pool is sized once in `bind` and never grows.
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
const address = @import("../platform/address.zig");
|
||||||
const handler = @import("handler.zig");
|
const handler = @import("handler.zig");
|
||||||
const transport = @import("../upstream/transport.zig");
|
const transport = @import("../upstream/transport.zig");
|
||||||
|
|
||||||
@@ -75,10 +76,14 @@ pub const UdpServer = struct {
|
|||||||
/// The datagram actually sent stays bounded by `udpLimit` inside the
|
/// The datagram actually sent stays bounded by `udpLimit` inside the
|
||||||
/// handler, so nothing larger than 4096 bytes leaves this socket.
|
/// handler, so nothing larger than 4096 bytes leaves this socket.
|
||||||
///
|
///
|
||||||
/// Cost: 4096 + 65535 ≈ 68 KiB per slot, so the default 64 slots hold
|
/// Cost: 4096 + 65535 + the scratch below ≈ 74 KiB per slot, so the
|
||||||
/// ≈ 4.3 MiB. The PLAN §18 budget is 100 MB with ~1M blocked domains,
|
/// default 64 slots hold ≈ 4.6 MiB. The PLAN §18 budget is 100 MB with
|
||||||
/// so this pool takes about 4% of it.
|
/// ~1M blocked domains, so this pool takes about 5% of it.
|
||||||
reply: [transport.max_message_len]u8,
|
reply: [transport.max_message_len]u8,
|
||||||
|
/// The handler's per-query working memory. It belongs to the slot so
|
||||||
|
/// that answering a datagram still allocates nothing, and one slot
|
||||||
|
/// serves one query at a time.
|
||||||
|
scratch: handler.Scratch,
|
||||||
from: std.Io.net.IpAddress,
|
from: std.Io.net.IpAddress,
|
||||||
len: usize,
|
len: usize,
|
||||||
/// Guarded by `UdpServer.mutex`.
|
/// Guarded by `UdpServer.mutex`.
|
||||||
@@ -90,7 +95,7 @@ pub const UdpServer = struct {
|
|||||||
pub fn bind(
|
pub fn bind(
|
||||||
gpa: std.mem.Allocator,
|
gpa: std.mem.Allocator,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
address: std.Io.net.IpAddress,
|
bind_address: std.Io.net.IpAddress,
|
||||||
h: *handler.Handler,
|
h: *handler.Handler,
|
||||||
options: Options,
|
options: Options,
|
||||||
) BindError!UdpServer {
|
) BindError!UdpServer {
|
||||||
@@ -100,7 +105,7 @@ pub const UdpServer = struct {
|
|||||||
errdefer gpa.free(slots);
|
errdefer gpa.free(slots);
|
||||||
for (slots) |*slot| slot.in_use = false;
|
for (slots) |*slot| slot.in_use = false;
|
||||||
|
|
||||||
const local = address;
|
const local = bind_address;
|
||||||
const socket = try local.bind(io, .{ .mode = .dgram });
|
const socket = try local.bind(io, .{ .mode = .dgram });
|
||||||
|
|
||||||
return .{
|
return .{
|
||||||
@@ -204,7 +209,15 @@ pub const UdpServer = struct {
|
|||||||
const slot = &self.slots[index];
|
const slot = &self.slots[index];
|
||||||
defer self.release(io, index);
|
defer self.release(io, index);
|
||||||
|
|
||||||
switch (self.handler.handle(io, .udp, slot.query[0..slot.len], &slot.reply)) {
|
const outcome = self.handler.handle(
|
||||||
|
io,
|
||||||
|
.udp,
|
||||||
|
address.NetAddress.fromIp(slot.from),
|
||||||
|
slot.query[0..slot.len],
|
||||||
|
&slot.reply,
|
||||||
|
&slot.scratch,
|
||||||
|
);
|
||||||
|
switch (outcome) {
|
||||||
.drop => bump(&self.stats.dropped_handler),
|
.drop => bump(&self.stats.dropped_handler),
|
||||||
.reply => |bytes| self.socket.send(io, &slot.from, bytes) catch |err| {
|
.reply => |bytes| self.socket.send(io, &slot.from, bytes) catch |err| {
|
||||||
bump(&self.stats.send_errors);
|
bump(&self.stats.send_errors);
|
||||||
@@ -283,8 +296,8 @@ test "a slot's reply buffer holds a whole DNS message" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test "the default slot pool stays inside the memory budget" {
|
test "the default slot pool stays inside the memory budget" {
|
||||||
// 4096 + 65535 ≈ 68 KiB per slot; 64 slots ≈ 4.3 MiB, against the 100 MB
|
// 4096 + 65535 + scratch ≈ 74 KiB per slot; 64 slots ≈ 4.6 MiB, against the
|
||||||
// of PLAN §18.
|
// 100 MB of PLAN §18.
|
||||||
const options: Options = .{};
|
const options: Options = .{};
|
||||||
const pool_bytes = @sizeOf(UdpServer.Slot) * @as(usize, options.max_in_flight);
|
const pool_bytes = @sizeOf(UdpServer.Slot) * @as(usize, options.max_in_flight);
|
||||||
try testing.expect(pool_bytes < 8 * 1024 * 1024);
|
try testing.expect(pool_bytes < 8 * 1024 * 1024);
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const net = std.Io.net;
|
|||||||
|
|
||||||
const handler = @import("handler.zig");
|
const handler = @import("handler.zig");
|
||||||
const udp_server = @import("udp_server.zig");
|
const udp_server = @import("udp_server.zig");
|
||||||
|
const model = @import("../config/model.zig");
|
||||||
|
const response = @import("../filter/response.zig");
|
||||||
|
const forward_zones = @import("../local/forward_zones.zig");
|
||||||
|
const records = @import("../local/records.zig");
|
||||||
const header = @import("../dns/header.zig");
|
const header = @import("../dns/header.zig");
|
||||||
const packet = @import("../dns/packet.zig");
|
const packet = @import("../dns/packet.zig");
|
||||||
const types = @import("../dns/types.zig");
|
const types = @import("../dns/types.zig");
|
||||||
@@ -21,6 +25,31 @@ const transport = @import("../upstream/transport.zig");
|
|||||||
|
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
||||||
|
const empty_records: records.Records = .empty;
|
||||||
|
const empty_zones: forward_zones.Zones = .empty;
|
||||||
|
const blocking_defaults: model.Blocking = .{};
|
||||||
|
const blocking: response.Options = .{
|
||||||
|
.mode = blocking_defaults.response,
|
||||||
|
.ttl = blocking_defaults.ttl,
|
||||||
|
};
|
||||||
|
const forward_timeout: std.Io.Clock.Duration = .{
|
||||||
|
.raw = model.readTimeout(.{}),
|
||||||
|
.clock = .awake,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// An upstream and nothing else optional: no filtering, no cache, no log. The
|
||||||
|
/// listener is what these tests exercise, so the handler is the same bare one
|
||||||
|
/// its own tests use.
|
||||||
|
fn bareHandler(client: transport.Client) handler.Handler {
|
||||||
|
return .{
|
||||||
|
.upstream = client,
|
||||||
|
.blocking = blocking,
|
||||||
|
.forward_read_timeout = forward_timeout,
|
||||||
|
.records = &empty_records,
|
||||||
|
.zones = &empty_zones,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Long enough that a loopback round trip cannot lose to scheduling, short
|
/// Long enough that a loopback round trip cannot lose to scheduling, short
|
||||||
/// enough that a broken server fails the run instead of hanging it.
|
/// enough that a broken server fails the run instead of hanging it.
|
||||||
const budget: std.Io.Timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } };
|
const budget: std.Io.Timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } };
|
||||||
@@ -87,7 +116,7 @@ test "a udp query is answered on the loopback" {
|
|||||||
const io = threaded.io();
|
const io = threaded.io();
|
||||||
|
|
||||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
var h = bareHandler(fake.client());
|
||||||
|
|
||||||
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 server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||||
@@ -125,7 +154,7 @@ test "a runt datagram is dropped and no reply is sent" {
|
|||||||
const io = threaded.io();
|
const io = threaded.io();
|
||||||
|
|
||||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
var h = bareHandler(fake.client());
|
||||||
|
|
||||||
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 server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||||
@@ -160,7 +189,7 @@ test "an oversize datagram arrives truncated and is dropped" {
|
|||||||
const io = threaded.io();
|
const io = threaded.io();
|
||||||
|
|
||||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
var h = bareHandler(fake.client());
|
||||||
|
|
||||||
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 server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||||
@@ -201,7 +230,7 @@ test "deinit ends a serve loop that is blocked on receive" {
|
|||||||
const io = threaded.io();
|
const io = threaded.io();
|
||||||
|
|
||||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
var h = bareHandler(fake.client());
|
||||||
|
|
||||||
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 server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
//! not appear in an export. `countClients` counts **all** rows, because S5's
|
//! not appear in an export. `countClients` counts **all** rows, because S5's
|
||||||
//! "has this database ever been configured" predicate needs the true count.
|
//! "has this database ever been configured" predicate needs the true count.
|
||||||
//!
|
//!
|
||||||
//! Only list / insert / deleteAll / count exist.
|
//! Only list / insert / deleteAll / count, plus the two runtime calls
|
||||||
|
//! `upsertSeen` and `pruneStale` that the Phase 7 client tracker owns.
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
@@ -81,6 +82,51 @@ pub fn insertClient(database: *db.Db, item: model.Client, ctx: InsertContext) db
|
|||||||
try stmt.exec();
|
try stmt.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const upsert_seen_sql =
|
||||||
|
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
|
||||||
|
\\VALUES (?1, NULL, (SELECT id FROM groups WHERE name = 'default'), 0, ?2, ?2)
|
||||||
|
\\ON CONFLICT(ip) DO UPDATE SET last_seen = excluded.last_seen
|
||||||
|
;
|
||||||
|
|
||||||
|
/// Materialises a client seen in live traffic (PLAN §7.2), or touches
|
||||||
|
/// `last_seen` on the row that already holds `ip`.
|
||||||
|
///
|
||||||
|
/// The conflict target is `clients.ip`, which the schema declares UNIQUE. Only
|
||||||
|
/// `last_seen` is updated: `name`, `group_id` and `hand_edited` are the
|
||||||
|
/// operator's, and a device that keeps querying must not overwrite them. A
|
||||||
|
/// hand-edited row is touched too, so the operator sees liveness for the
|
||||||
|
/// clients they named.
|
||||||
|
///
|
||||||
|
/// A new row lands in the `default` group. `groupForClient` resolves the real
|
||||||
|
/// group from the prefix rules at query time, so the column here only decides
|
||||||
|
/// what the operator sees before they assign the device themselves.
|
||||||
|
///
|
||||||
|
/// A database with no group named `default` fails the insert with
|
||||||
|
/// `error.Constraint` rather than writing a dangling row. `validate.zig`
|
||||||
|
/// rejects such a configuration long before a server serves from it.
|
||||||
|
pub fn upsertSeen(database: *db.Db, ip: []const u8, now_s: i64) db.Error!void {
|
||||||
|
var stmt = try database.prepare(upsert_seen_sql);
|
||||||
|
defer stmt.deinit();
|
||||||
|
try stmt.bindText(1, ip);
|
||||||
|
try stmt.bindInt(2, now_s);
|
||||||
|
try stmt.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the auto-materialised clients whose last query predates `cutoff_s`,
|
||||||
|
/// and returns how many rows went. `hand_edited = 1` rows are configuration and
|
||||||
|
/// survive any silence.
|
||||||
|
///
|
||||||
|
/// `last_seen` is the only signal, because §3.6 forbids joining `config.db`
|
||||||
|
/// against the query log.
|
||||||
|
pub fn pruneStale(database: *db.Db, cutoff_s: i64) db.Error!u32 {
|
||||||
|
var stmt = try database.prepare("DELETE FROM clients WHERE hand_edited = 0 AND last_seen < ?1");
|
||||||
|
defer stmt.deinit();
|
||||||
|
try stmt.bindInt(1, cutoff_s);
|
||||||
|
try stmt.exec();
|
||||||
|
const deleted = database.changes();
|
||||||
|
return @intCast(@min(deleted, std.math.maxInt(u32)));
|
||||||
|
}
|
||||||
|
|
||||||
pub fn deleteAllClients(database: *db.Db) db.Error!void {
|
pub fn deleteAllClients(database: *db.Db) db.Error!void {
|
||||||
return database.exec("DELETE FROM clients;");
|
return database.exec("DELETE FROM clients;");
|
||||||
}
|
}
|
||||||
@@ -281,6 +327,124 @@ test "listClients is leak-safe under allocation failure" {
|
|||||||
try testing.checkAllAllocationFailures(testing.allocator, listClientsUnderFailure, .{&ids});
|
try testing.checkAllAllocationFailures(testing.allocator, listClientsUnderFailure, .{&ids});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn seenRow(database: *db.Db, ip: []const u8) !struct { hand_edited: i64, first_seen: i64, last_seen: i64, group_id: i64 } {
|
||||||
|
var stmt = try database.prepare(
|
||||||
|
"SELECT hand_edited, first_seen, last_seen, group_id FROM clients WHERE ip = ?1",
|
||||||
|
);
|
||||||
|
defer stmt.deinit();
|
||||||
|
try stmt.bindText(1, ip);
|
||||||
|
try testing.expect(try stmt.step());
|
||||||
|
return .{
|
||||||
|
.hand_edited = stmt.columnInt(0),
|
||||||
|
.first_seen = stmt.columnInt(1),
|
||||||
|
.last_seen = stmt.columnInt(2),
|
||||||
|
.group_id = stmt.columnInt(3),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test "upsertSeen materialises an unseen client in the default group" {
|
||||||
|
var database = try openMigrated();
|
||||||
|
defer database.close();
|
||||||
|
|
||||||
|
try upsertSeen(&database, "192.168.1.50", 1700000000);
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(i64, 1), try countClients(&database));
|
||||||
|
const row = try seenRow(&database, "192.168.1.50");
|
||||||
|
try testing.expectEqual(@as(i64, 0), row.hand_edited);
|
||||||
|
try testing.expectEqual(@as(i64, 1700000000), row.first_seen);
|
||||||
|
try testing.expectEqual(@as(i64, 1700000000), row.last_seen);
|
||||||
|
try testing.expectEqual(@as(i64, 1), row.group_id);
|
||||||
|
|
||||||
|
// Materialised clients are runtime state, so an export must not see them.
|
||||||
|
var items = try listClients(&database, testing.allocator);
|
||||||
|
defer items.deinit(testing.allocator);
|
||||||
|
defer freeClients(testing.allocator, items.items);
|
||||||
|
try testing.expectEqual(@as(usize, 0), items.items.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "upsertSeen touches last_seen and leaves first_seen alone" {
|
||||||
|
var database = try openMigrated();
|
||||||
|
defer database.close();
|
||||||
|
|
||||||
|
try upsertSeen(&database, "192.168.1.50", 1700000000);
|
||||||
|
try upsertSeen(&database, "192.168.1.50", 1700000600);
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(i64, 1), try countClients(&database));
|
||||||
|
const row = try seenRow(&database, "192.168.1.50");
|
||||||
|
try testing.expectEqual(@as(i64, 1700000000), row.first_seen);
|
||||||
|
try testing.expectEqual(@as(i64, 1700000600), row.last_seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "upsertSeen keeps a hand-edited row's name, group and flag" {
|
||||||
|
var database = try openMigrated();
|
||||||
|
defer database.close();
|
||||||
|
var ids = try seedGroups(&database);
|
||||||
|
defer ids.deinit(testing.allocator);
|
||||||
|
try seedClients(&database, &ids);
|
||||||
|
|
||||||
|
try upsertSeen(&database, "192.168.1.20", 1700009999);
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(i64, 3), try countClients(&database));
|
||||||
|
const row = try seenRow(&database, "192.168.1.20");
|
||||||
|
try testing.expectEqual(@as(i64, 1), row.hand_edited);
|
||||||
|
try testing.expectEqual(@as(i64, 2), row.group_id);
|
||||||
|
try testing.expectEqual(@as(i64, 1700000000), row.first_seen);
|
||||||
|
try testing.expectEqual(@as(i64, 1700009999), row.last_seen);
|
||||||
|
|
||||||
|
var items = try listClients(&database, testing.allocator);
|
||||||
|
defer items.deinit(testing.allocator);
|
||||||
|
defer freeClients(testing.allocator, items.items);
|
||||||
|
try testing.expectEqual(@as(usize, 3), items.items.len);
|
||||||
|
try testing.expectEqualStrings("laptop", items.items[1].name);
|
||||||
|
try testing.expectEqualStrings("kids", items.items[1].group);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "upsertSeen reports a database with no default group" {
|
||||||
|
var database = try openMigrated();
|
||||||
|
defer database.close();
|
||||||
|
try database.exec("UPDATE groups SET name = 'renamed' WHERE id = 1;");
|
||||||
|
|
||||||
|
try testing.expectError(error.Constraint, upsertSeen(&database, "192.168.1.50", 1700000000));
|
||||||
|
try testing.expectEqual(@as(i64, 0), try countClients(&database));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "pruneStale removes only stale auto-materialised rows" {
|
||||||
|
var database = try openMigrated();
|
||||||
|
defer database.close();
|
||||||
|
var ids = try seedGroups(&database);
|
||||||
|
defer ids.deinit(testing.allocator);
|
||||||
|
// A hand-edited row far older than the cutoff.
|
||||||
|
try seedClients(&database, &ids);
|
||||||
|
|
||||||
|
try upsertSeen(&database, "10.0.0.1", 1700000000);
|
||||||
|
try upsertSeen(&database, "10.0.0.2", 1700000199);
|
||||||
|
// Exactly at the cutoff: the comparison is strict, so it stays.
|
||||||
|
try upsertSeen(&database, "10.0.0.3", 1700000200);
|
||||||
|
try upsertSeen(&database, "10.0.0.4", 1700000300);
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(u32, 2), try pruneStale(&database, 1700000200));
|
||||||
|
try testing.expectEqual(@as(i64, 5), try countClients(&database));
|
||||||
|
try testing.expectEqual(
|
||||||
|
@as(i64, 0),
|
||||||
|
try database.queryInt("SELECT count(*) FROM clients WHERE ip IN ('10.0.0.1', '10.0.0.2')"),
|
||||||
|
);
|
||||||
|
try testing.expectEqual(@as(i64, 3), try database.queryInt("SELECT count(*) FROM clients WHERE hand_edited = 1"));
|
||||||
|
|
||||||
|
// A second pass over the same cutoff finds nothing left to do.
|
||||||
|
try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1700000200));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "pruneStale spares hand-edited rows however stale" {
|
||||||
|
var database = try openMigrated();
|
||||||
|
defer database.close();
|
||||||
|
var ids = try seedGroups(&database);
|
||||||
|
defer ids.deinit(testing.allocator);
|
||||||
|
try seedClients(&database, &ids);
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1800000000));
|
||||||
|
try testing.expectEqual(@as(i64, 3), try countClients(&database));
|
||||||
|
}
|
||||||
|
|
||||||
test "client_prefixes round-trip in prefix order with group names resolved" {
|
test "client_prefixes round-trip in prefix order with group names resolved" {
|
||||||
var database = try openMigrated();
|
var database = try openMigrated();
|
||||||
defer database.close();
|
defer database.close();
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ comptime {
|
|||||||
_ = @import("platform/logging.zig");
|
_ = @import("platform/logging.zig");
|
||||||
_ = @import("storage/retention.zig");
|
_ = @import("storage/retention.zig");
|
||||||
_ = @import("storage/phase6_integration_test.zig");
|
_ = @import("storage/phase6_integration_test.zig");
|
||||||
|
_ = @import("server/pause.zig");
|
||||||
|
_ = @import("server/clients.zig");
|
||||||
|
_ = @import("server/shutdown.zig");
|
||||||
|
_ = @import("server/phase7_integration_test.zig");
|
||||||
}
|
}
|
||||||
|
|
||||||
extern fn sqlite3_libversion() [*:0]const u8;
|
extern fn sqlite3_libversion() [*:0]const u8;
|
||||||
|
|||||||
Reference in New Issue
Block a user