12 KiB
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.dbis the truth. Schema DDL is carried verbatim bymigrations.zigas step 1; a schema change is a new migration step, applied inside one transaction.nxdns importreplaces its whole content atomically (BEGIN IMMEDIATE; a failed import changes nothing),nxdns exportrenders 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.dbis 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.dbis 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.