Files
nxdns/docs/architecture.md
T

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