milestone 11: systemd and docker packaging, operator and architecture docs, config and api reference, docs drift guards

This commit is contained in:
2026-08-02 15:24:10 +02:00
parent a589df7515
commit bdb6ffab7a
29 changed files with 1936 additions and 94 deletions
+182
View File
@@ -0,0 +1,182 @@
# Architecture
nxdns is a self-hosted DNS sinkhole for a household LAN: one static Zig binary
that answers DNS on UDP/TCP 53 (optionally DoH and DoT), filters against
blocklists, and serves an embedded admin SPA over HTTP. This document is a map
of the source tree and the few design rules that hold everywhere.
## Module map
Top-level files:
| File | Role |
|---|---|
| `src/main.zig` | Process shell: writers, argv, dispatch, exit code. |
| `src/cli.zig` | Every command body (`run`, `check`, `export`, `import`, `version`, `help`); takes its writers as parameters so tests capture output without a process. |
| `src/app.zig` | The composition root: everything `nxdns run` owns, built in order. Nothing else constructs a collaborator. |
| `src/version.zig` | Build-time version strings. |
| `src/tests.zig` | Test root; imports each file directly. |
Directories:
| Directory | Role |
|---|---|
| `src/dns/` | Pure DNS wire format: header, names, questions, records, whole packets, EDNS(0)/ECS (`edns.zig`), enums and limits (`types.zig`). No allocation, no `std.Io` beyond writing to a caller's writer. |
| `src/filter/` | Blocklist pipeline: line parsers (hosts, domains, ABP), the compiler that turns a downloaded list into `.list`/`.wild` bodies, `domain_set.zig` (exact-match set, no Bloom filter), `matcher.zig` (the immutable snapshot every query evaluates against), per-group `rules.zig`, `wildcard.zig`, `safesearch.zig`, blocked-response synthesis (`response.zig`). Two I/O edges live here too: `fetcher.zig` (HTTP download) and `manager.zig` (files + DB + snapshot swap). |
| `src/local/` | Local DNS records and conditional forward zones: immutable lookup tables built once from DB rows (`records.zig`, `forward_zones.zig`), plus the plain UDP/TCP client for LAN resolvers (`forward_client.zig`). |
| `src/cache/` | `dns_cache.zig`: bounded in-memory TTL cache of whole response messages, keyed by the question. The clock arrives as a parameter. |
| `src/upstream/` | Upstream resolution: shared vocabulary and the `Client` interface (`transport.zig`), DoH client (RFC 8484), DoT client (RFC 7858), per-endpoint health/backoff (`health.zig`), and `pool.zig` — priority-ordered sequential failover that is itself a `transport.Client`, so the handler sees one interface. |
| `src/server/` | The serving side: UDP/TCP/DoH/DoT listeners, `handler.zig` (the whole query pipeline), `cert_store.zig` (refcounted TLS cert holder), `rate_limiter.zig`, `pause.zig`, `clients.zig` (client auto-materialisation), `local_tables.zig` (published local-answer tables), `query_sink.zig` (log/SSE fanout), `shutdown.zig` (SIGINT/SIGTERM → one `std.Io.Event`). |
| `src/storage/` | SQLite ownership: `db.zig` is the only file that calls SQLite, `config_schema.zig` + `migrations.zig` for `config.db`, `querylog_schema.zig` (open-or-recreate), async query `logger.zig`, `retention.zig`, `disk_monitor.zig`, and one repository per table under `repositories/`. |
| `src/config/` | The one configuration model (`model.zig`), the pure validator (`validate.zig`), `import.zig`/`export.zig` (ZON ⇄ `config.db`, byte-stable round trip), `bootstrap.zig` (first-start seeding — a policy wrapper over import). |
| `src/web/` | The admin HTTP layer: `server.zig` (listener), `router.zig`/`routes.zig`, one file per resource under `handlers/`, `auth.zig` (sessions), `sse.zig` (live query fanout), `static.zig` (embedded SPA), `metrics.zig` (Prometheus), `openapi.zig` (served contract), `api_limiter.zig`, `http_util.zig`. |
| `src/platform/` | OS and TLS edges: IP address values, the `std.log` sink (`logging.zig`), `statfs.zig` (free-space query via libc), client TLS over `std.crypto.tls` (`tls_client.zig`), server TLS over vendored Mbed TLS (`tls_server.zig`). |
The SPA source lives in `web/` at the repo root; the build embeds its `dist/`
output as the `web_assets` module (`-Dweb-dist`).
```
main.zig ── cli.zig ── app.zig (composition root)
│ injects std.Io + collaborators
┌──────────────────────┴───────────────────────┐
│ server/ web/ upstream/ storage/ │ I/O edge
│ platform/ config/{import,export,bootstrap} │
├──────────────────────────────────────────────┤
│ dns/ filter/* local/* cache/ │ pure core:
│ config/{model,validate} │ bytes in, bytes out
└──────────────────────────────────────────────┘
* except filter/{fetcher,manager}.zig and local/forward_client.zig,
which are those directories' named I/O edges
```
## The purity rule
`dns/`, `filter/`, `local/` and `cache/` take bytes and return bytes: no
`std.Io`, no sockets, no clocks hidden inside (AGENTS.md). Anything that needs
a timestamp takes it as a parameter — the cache, the rate limiter and the
pause flag all work this way, so every decision is testable without a backend.
The exceptions are deliberate and few: `filter/fetcher.zig` downloads lists,
`filter/manager.zig` owns the compiled files, the DB columns and the snapshot
swap, and `local/forward_client.zig` speaks UDP/TCP to a LAN resolver. The
decision path a query takes through these directories allocates nothing and
opens nothing.
## std.Io injection
There is one `std.Io` in the process. `main` receives it through
`std.process.Init` — on the standard start path this is the Threaded backend
(`std.Io.Threaded`, constructed in the stdlib's start code) — and hands it to
`cli.Runner`, from which `app.zig` threads it into every collaborator as a
parameter. No module constructs its own event loop or reads an ambient clock;
tests build their own `std.Io.Threaded` instance and pass it the same way.
The one deliberate exception is `storage/db.zig`: SQLite performs its own file
I/O through its VFS, so that file takes no `std.Io` at all.
## Life of one query
The pipeline in `src/server/handler.zig` (its order is PLAN §4; the stages
below are the code's actual call chain — `Handler.handle` then `Context.run`):
```
UDP/53 TCP/53 DoH DoT (src/server/{udp,tcp,doh,dot}_server.zig)
└──────┴──────┴────┘
│ raw query bytes, listener-owned buffers
handler.handle
├─ header parse (too short / QR set → counted drop)
├─ rate limit (over budget → REFUSED)
├─ packet + EDNS validation (FORMERR / NOTIMP)
├─ client tracking, group lookup (snapshot.groupForClient)
├─ local records ────────────────► authoritative answer
├─ forward zones ─► cache ─► LAN resolver ─► answer
└─ upstream path
├─ filter snapshot evaluate ─► blocked? synthesized block reply
├─ safe-search rewrite (per group)
├─ cache get ─► hit? answer
├─ upstream pool: priority failover across DoH/DoT endpoints
├─ CNAME uncloak: walk the answer's chain, re-evaluate each target
└─ cache put
reply bytes ─► listener sends
└─► QuerySink ─► SSE hub (GET /api/queries/live)
└─► async logger ─► querylog.db
```
Local records win over forward zones, and both win over filtering: a name
nxdns answers itself never reaches a blocklist. Pause suspends filtering only;
local records, forward zones, cache, upstream and the query log keep running.
`handle` returns no error union — every failure is either a DNS response the
client can act on or a counted drop. The query path never waits on the
database: `QuerySink` copies the entry, the SSE hub gets it first, and one
writer task owns the `querylog.db` handle behind an `std.Io.Queue`.
## Storage
Two databases with opposite contracts:
- **`config.db` is the truth.** Schema DDL is carried verbatim by
`migrations.zig` as step 1; a schema change is a new migration step, applied
inside one transaction. `nxdns import` replaces its whole content atomically
(`BEGIN IMMEDIATE`; a failed import changes nothing), `nxdns export` renders
it back as canonical ZON, byte-identical across round trips. A config file
seeds the DB exactly once at first start (`config/bootstrap.zig`); the DB is
truth thereafter.
- **`querylog.db` is expendable.** It is never migrated: its schema carries a
fingerprint derived from the DDL text, and a mismatch at open replaces the
file (`storage/querylog_schema.zig`). Retention deletes old rows daily and
periodically rewrites the file; `config.db` is walled off from that churn.
## Web stack
`web/server.zig` runs one `std.http.Server` per connection over its own accept
loop, with fixed pre-allocated connection slots, optionally behind TLS. The
SPA is embedded at build time: `static.zig` serves the `web_assets` module —
bytes, content type, strong ETag, and a pre-compressed `.gz` sibling where it
paid off — via a linear scan, no filesystem at runtime. `GET /api/queries/live`
is server-sent events over chunked transfer, fed by the same `QuerySink` the
logger reads. Routing is a flat table (`routes.zig`) matched linearly; the
OpenAPI YAML is hand-written, embedded and served at `GET /api/openapi.yaml`,
kept honest by tests that assert every served route appears in it.
Auth (`web/auth.zig`): the operator's password is verified against an argon2id
PHC string (`web.password_hash` — the plaintext is hashed on import and never
stored). A successful login mints a 256-bit token carried in a cookie; the
in-memory session table holds only SHA-256 digests of tokens, compared in
constant time, capped at 32 sessions with LRU eviction. Nothing is persisted —
a restart logs everyone out. Monitoring endpoints (health, version, metrics),
the served OpenAPI contract and login itself are unauthenticated; everything
else requires the cookie, and
the API has its own token-bucket rate limiter.
## DoH, DoT and certificate hot-reload
`server/doh_server.zig` (RFC 8484 over HTTP/1.1 + TLS) and
`server/dot_server.zig` (RFC 7858) mirror the plain listeners' shape. Server
TLS terminates in Mbed TLS (`platform/tls_server.zig`), exposing plaintext as
`std.Io.Reader`/`std.Io.Writer`.
Certificates hot-reload through `server/cert_store.zig`: one refcounted
`CertStore` per endpoint owns the published TLS context generation; listeners
`acquire` it per connection and `release` it when the connection ends, so a
reload never frees a context mid-handshake. Reload publishes nothing on
failure — both PEM files are read and a whole new context built before
anything swaps, and any failure leaves the old generation serving. A watcher
polls mtime+size of both files every 30 s; `POST /api/certs/reload` triggers
the same path on demand and reports the per-endpoint outcome as its payload.
## Failure visibility
Every failure mode must be visible, and the surface is counters, not log
lines (AGENTS.md). The handler counts every outcome — drops, FORMERR,
SERVFAIL, blocked, truncated, cache hits, paused and unfiltered queries — in
atomics; listeners count dropped datagrams instead of queueing them
unboundedly. `GET /metrics` renders all of it as Prometheus text 0.0.4, and
`GET /api/health` rolls it up for a monitor (always 200: "degraded" is a fact
about the box, not a failed request). The disk monitor classifies free space
against thresholds and gates non-essential writes; the query logger holds its
batches while writes are disallowed. `std.log` is reserved for failures
nobody else records, with upstream-error deduplication so a flapping resolver
cannot fill a disk.