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.
|
||||
Reference in New Issue
Block a user