Files
nxdns/specs/milestone-7.md

701 lines
46 KiB
Markdown

# 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.