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
+80
View File
@@ -136,3 +136,83 @@ jobs:
exit 1 exit 1
fi fi
done done
docker:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Set up Zig
uses: mlugg/setup-zig@v2
with:
version: ${{ env.ZIG_VERSION }}
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: web/package-lock.json
- name: Build the web UI
working-directory: web
run: |
npm ci
npm run build
- name: Build static musl executables
run: zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
- name: Build the image
run: docker build -t nxdns:ci -f deploy/docker/Dockerfile .
- name: Smoke test the container
run: |
set -euo pipefail
docker run --rm nxdns:ci version
mkdir -p etc-nxdns
cat > etc-nxdns/config.zon <<'EOF'
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
}
EOF
cid=$(docker run -d --name nxdns-smoke \
-p 127.0.0.1:8080:8080 \
-v "$PWD/etc-nxdns:/etc/nxdns:ro" \
nxdns:ci)
trap 'docker rm -f nxdns-smoke >/dev/null 2>&1 || true' EXIT
# The published port works when the job runs on the docker host or in
# DinD; the container IP covers a runner that shares the daemon over
# a mounted socket.
ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$cid")
healthy=""
for _ in $(seq 1 30); do
if [ "$(docker inspect -f '{{.State.Running}}' "$cid")" != "true" ]; then
echo "container exited during startup"
docker logs "$cid" || true
exit 1
fi
if curl -fsS "http://127.0.0.1:8080/api/health" \
|| { [ -n "$ip" ] && curl -fsS "http://$ip:8080/api/health"; }; then
healthy=1
break
fi
sleep 1
done
if [ -z "$healthy" ]; then
echo "no /api/health response within 30 seconds"
docker logs "$cid" || true
exit 1
fi
docker stop -t 30 nxdns-smoke
exit_code=$(docker inspect -f '{{.State.ExitCode}}' nxdns-smoke)
echo "exit code after SIGTERM: $exit_code"
docker logs nxdns-smoke || true
test "$exit_code" -eq 0
+2 -3
View File
@@ -54,7 +54,7 @@ Serves a household LAN (≈220 devices). Portfolio-grade public repo with ext
Zig 0.16 stdlib is built on the `std.Io` interface: `std.Io.net` owns networking (`std.net` in its old form is gone) and `std.http.Client` requires an `io: Io`. Therefore: Zig 0.16 stdlib is built on the `std.Io` interface: `std.Io.net` owns networking (`std.net` in its old form is gone) and `std.http.Client` requires an `io: Io`. Therefore:
- **`Io` is the injected platform abstraction.** Every component that does I/O takes `io: Io`. No project-owned wrapper interfaces around it — a second abstraction over an abstraction with one consumer is waste. - **`Io` is the injected platform abstraction.** Every component that does I/O takes `io: Io`. No project-owned wrapper interfaces around it — a second abstraction over an abstraction with one consumer is waste.
- **Backend: `std.Io.Threaded` by default** — the mature, debuggable path; at ≤20 devices throughput is a non-issue. `std.Io.Evented` (io_uring) is selectable via config flag; the code is backend-agnostic by construction, so this is a switch, not a refactor. - **Backend: `std.Io.Threaded`** — the mature, debuggable path; at ≤20 devices throughput is a non-issue. The originally planned io_uring config flag was dropped in milestone 11: `std.Io.Evented` at Zig 0.16.0 stubs the networking a server needs — listen, accept, connect, lookup, and stream reads/writes return `error.NetworkDown` (`Uring.zig` netListenIp/netAccept/netConnectIp/netRead/netWrite) — so a selectable backend would boot a dead server. The code stays backend-agnostic by construction; revisit when std ships working evented networking.
- No hand-written thread pool. `Io` async/concurrent/Group covers task management. - No hand-written thread pool. `Io` async/concurrent/Group covers task management.
- Core domain modules (`dns`, `filter`, `cache`) stay pure: no `Io`, no sockets — bytes in, bytes out. Only servers, upstream clients, and storage touch `Io`. - Core domain modules (`dns`, `filter`, `cache`) stay pure: no `Io`, no sockets — bytes in, bytes out. Only servers, upstream clients, and storage touch `Io`.
@@ -476,7 +476,6 @@ Periodic delete of rows older than `retention_days`; scheduled checkpoint/VACUUM
.{ .{
.upstream = .{ .upstream = .{
.servers = .{ "https://cloudflare-dns.com/dns-query", "tls://dns.google:853" }, .servers = .{ "https://cloudflare-dns.com/dns-query", "tls://dns.google:853" },
.connect_timeout_ms = 2000,
.read_timeout_ms = 3000, .read_timeout_ms = 3000,
}, },
.dns = .{ .dns = .{
@@ -683,7 +682,7 @@ Exit: documented deployment works end-to-end on the Pi 5.
| B | Rule kinds: exact, parent-walk, wildcard. Regex permanently out of scope | | B | Rule kinds: exact, parent-walk, wildcard. Regex permanently out of scope |
| C | In scope: local DoH/DoT server, local records, conditional forwarding. Out: HTTP/2, DoQ, DHCP, DNSSEC, clustering | | C | In scope: local DoH/DoT server, local records, conditional forwarding. Out: HTTP/2, DoQ, DHCP, DNSSEC, clustering |
| D | mbedTLS (vendored) terminates server TLS; stdlib TLS for upstream client | | D | mbedTLS (vendored) terminates server TLS; stdlib TLS for upstream client |
| E | `std.Io` injected everywhere; `Threaded` default backend, io_uring via flag; no custom thread pool | | E | `std.Io` injected everywhere; `Threaded` backend (io_uring flag dropped in m11 — Evented networking is stubbed at 0.16.0); no custom thread pool |
| F | Config format: ZON via `std.zon`; DB is truth; export/import for backup + host moves | | F | Config format: ZON via `std.zon`; DB is truth; export/import for backup + host moves |
| G | SQLite vendored amalgamation + own thin wrapper | | G | SQLite vendored amalgamation + own thin wrapper |
| H | Two DBs: `config.db` (precious) + `querylog.db` (expendable, self-contained, client IP as text) | | H | Two DBs: `config.db` (precious) + `querylog.db` (expendable, self-contained, client IP as text) |
+72
View File
@@ -0,0 +1,72 @@
# nxdns
A self-hosted DNS sinkhole for a household LAN, written in Zig 0.16. One
static musl binary, SQLite for state, a Raspberry Pi 5 as the reference
target. It answers your network's DNS, blocks what you tell it to, and shows
you what asked for what.
## Features
- Blocklist filtering: subscribe to hosts/domain lists, plus your own allow
and block rules with wildcard support (`*.example.com`)
- Per-client policy groups: different filtering for the kids' tablet and
your workstation
- Local DNS records and conditional forwarding for internal zones
- Encrypted upstreams: DNS-over-HTTPS and DNS-over-TLS with failover
- Built-in DoH and DoT server endpoints, with certificate hot-reload
- Bounded in-memory DNS cache with TTL-respecting expiry
- Query log with retention limits, live-streamed over SSE
- Web UI (embedded in the binary) and a REST API with a served OpenAPI spec
- Prometheus-style `/metrics`, per-client rate limiting, disk-full
self-protection
## Quickstart (docker compose)
Build the binary and image, seed a minimal configuration, start it:
```sh
(cd web && npm ci && npm run build)
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
cd deploy/docker
mkdir -p etc-nxdns
cat > etc-nxdns/config.zon <<'EOF'
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
.web = .{ .password = "choose-a-real-password" },
}
EOF
docker compose up -d
```
DNS is on port 53, the web UI on <http://localhost:8080>. The config file
seeds the database on first boot only; from then on the database is the
truth and changes go through the UI, the API, or `nxdns export` /
`nxdns import`. Full install instructions, including the systemd path and
the Pi 5 recipe, are in [docs/operator.md](docs/operator.md).
## Building from source
Requires [Zig 0.16.0](https://ziglang.org/download/) and Node.js 24 (for
the web UI). C dependencies (SQLite, mbedTLS) are vendored and built by
`zig build`.
```sh
(cd web && npm ci && npm run build) # web UI -> web/dist
zig build -Dweb-dist=web/dist # native binary -> zig-out/bin/nxdns
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
# static x86_64 + aarch64 musl binaries
zig build test --summary all # unit tests
```
## Documentation
- [docs/operator.md](docs/operator.md) — install, configure, back up,
upgrade, troubleshoot
- [docs/architecture.md](docs/architecture.md) — module map and design
- [docs/config-reference.md](docs/config-reference.md) — every
configuration field
- [docs/api.md](docs/api.md) — REST API, auth and SSE
- [PLAN.md](PLAN.md) and [specs/](specs/) — scope, design decisions and
per-milestone contracts
+3
View File
@@ -61,6 +61,9 @@ pub fn build(b: *std.Build) void {
tests.root_module.addAnonymousImport("test_fixtures", .{ tests.root_module.addAnonymousImport("test_fixtures", .{
.root_source_file = b.path("tests/fixtures/fixtures.zig"), .root_source_file = b.path("tests/fixtures/fixtures.zig"),
}); });
tests.root_module.addAnonymousImport("docs_files", .{
.root_source_file = b.path("docs/docs.zig"),
});
tests.root_module.addAnonymousImport("web_assets", .{ .root_source_file = web_assets }); tests.root_module.addAnonymousImport("web_assets", .{ .root_source_file = web_assets });
const test_step = b.step("test", "Run the test suite"); const test_step = b.step("test", "Run the test suite");
test_step.dependOn(&b.addRunArtifact(tests).step); test_step.dependOn(&b.addRunArtifact(tests).step);
+6
View File
@@ -0,0 +1,6 @@
# The Dockerfile copies only zig-out/cross out of the repository-root context,
# and BuildKit transfers only referenced paths. This file guards the classic
# (non-BuildKit) builder, which would otherwise send the whole tree: copy it to
# the repository root as .dockerignore before building without BuildKit.
*
!zig-out/cross
+33
View File
@@ -0,0 +1,33 @@
# The binary is NOT compiled here. Build it first, from the repository root:
#
# (cd web && npm ci && npm run build)
# zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
#
# then build the image with the repository root as context:
#
# docker build -t nxdns -f deploy/docker/Dockerfile .
#
# The builder stage stages the CA bundle (upstream DoH/DoT verification rescans
# the system store; a scratch image without one breaks every TLS upstream) and
# maps the buildx TARGETARCH onto the zig cross-target directory.
FROM alpine:3.22 AS builder
RUN apk add --no-cache ca-certificates
ARG TARGETARCH
COPY zig-out/cross /cross
RUN mkdir -p /rootfs/etc/ssl/certs /rootfs/etc/nxdns /rootfs/var/lib/nxdns \
&& cp /etc/ssl/certs/ca-certificates.crt /rootfs/etc/ssl/certs/ \
&& case "${TARGETARCH:-amd64}" in \
amd64) cp /cross/x86_64-linux-musl/nxdns /rootfs/nxdns ;; \
arm64) cp /cross/aarch64-linux-musl/nxdns /rootfs/nxdns ;; \
*) echo "unsupported TARGETARCH '${TARGETARCH}'" >&2; exit 1 ;; \
esac \
&& chown 65532:65532 /rootfs/var/lib/nxdns
FROM scratch
COPY --from=builder /rootfs/ /
USER 65532:65532
VOLUME /var/lib/nxdns
EXPOSE 53/udp 53/tcp 8080 443 853
ENTRYPOINT ["/nxdns"]
CMD ["run"]
+31
View File
@@ -0,0 +1,31 @@
services:
nxdns:
image: nxdns
build:
context: ../..
dockerfile: deploy/docker/Dockerfile
restart: unless-stopped
# First boot needs ./etc-nxdns/config.zon with a `default` group and at
# least one enabled upstream, or the container exits with code 2. The file
# seeds the database once; after that the database is the truth and the
# file is ignored.
volumes:
- ./etc-nxdns:/etc/nxdns:ro
- nxdns-data:/var/lib/nxdns
ports:
- "53:53/udp"
- "53:53/tcp"
- "8080:8080"
# DoH/DoT listeners, off by default in the config:
# - "443:443"
# - "853:853"
# Per-network-namespace sysctl: lets uid 65532 bind port 53 inside the
# container without CAP_NET_BIND_SERVICE.
sysctls:
net.ipv4.ip_unprivileged_port_start: 0
# Do not point the host's resolv.conf at nxdns itself: the container's own
# lookups (upstream DoH/DoT hostnames) would then depend on the service
# they are trying to start.
volumes:
nxdns-data:
+49
View File
@@ -0,0 +1,49 @@
[Unit]
Description=nxdns DNS sinkhole
Documentation=https://git.mial.net/mokhtar/nxdns
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=nxdns
Group=nxdns
ExecStart=/usr/local/bin/nxdns run
# nxdns logs to stderr by default; systemd captures it into the journal.
StateDirectory=nxdns
StateDirectoryMode=0700
LogsDirectory=nxdns
ConfigurationDirectory=nxdns
# Port 53 (and 443/853 when the DoH/DoT listeners are enabled).
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
UMask=0077
SystemCallFilter=@system-service
SystemCallArchitectures=native
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
+1
View File
@@ -0,0 +1 @@
u nxdns - "nxdns DNS sinkhole"
+169
View File
@@ -0,0 +1,169 @@
# nxdns REST API
nxdns serves its admin API itself, on `web.bind:web.port` (default port 8080),
as plain HTTP. TLS termination, where an operator wants it, belongs to a
reverse proxy in front; the session cookie deliberately omits the `Secure`
attribute so the supported plain-HTTP LAN deployment works.
This page is orientation. The machine-readable contract is
`src/web/openapi.yaml`, which the running server hands out unauthenticated at
`GET /api/openapi.yaml`. When this page and the yaml disagree, the yaml wins.
## Conventions
- All request and response bodies are JSON (`application/json`), except
`/metrics` (Prometheus text format), `/api/openapi.yaml` (YAML) and
`/api/queries/live` (`text/event-stream`).
- Field names are snake_case, matching settings keys and SQL column names.
- Every error response carries the envelope `{"error": "<message>"}`. The
message is operator-facing text; internal detail never reaches the wire —
a 500 body is generic and the cause goes to the server log.
- Request bodies are strict: an unknown field is a 400, a body over 1 MiB is
a 413.
- A request whose path matches but whose method does not answers 405 with an
`Allow` header. An unknown `/api` path is a JSON 404; unknown non-`/api`
paths fall through to the embedded SPA (`index.html`), so client-side
routing works.
- Item routes (`{id}`) match a positive integer id only.
- Mutations to groups, blocklists, rules, local records, forward zones,
clients and client prefixes take effect live. Upstreams and `/api/settings`
are restart-required.
## Authentication
Cookie sessions, in memory, no accounts — one operator password.
- Authentication is on exactly when `web.password_hash` is set. When no
password is set, every route is open and `POST /api/auth/login` answers
`{"authenticated": true, "auth_required": false}` without setting a cookie.
- `POST /api/auth/login` takes `{"password": "..."}`. A correct password
answers 200 with a `Set-Cookie` for `nxdns_session`
(`HttpOnly; SameSite=Lax; Path=/`, `Max-Age` = the session TTL). A wrong
password is a 401; a stored hash the server cannot read is a 500, never a
401. Login attempts spend rate-limit tokens like any other request, and
argon2id verification is deliberately slow.
- Every route whose auth policy is `session` answers
401 `{"error": "authentication required"}` without a valid cookie.
- Sessions live `web.session_ttl_hours` (default 24) from login; use does not
extend the lifetime. The table holds 32 sessions; a 33rd login evicts the
least recently used. Nothing is persisted — a server restart logs every
operator out.
- Changing the password through `PUT /api/settings` revokes every live
session immediately; the new password applies without a restart.
- `POST /api/auth/logout` ends the cookie's session and clears the cookie.
It answers 200 whether or not the session was live.
## Rate limiting
A token bucket per client address: capacity and refill are both
`web.api_rate_limit_per_min` (default 300) per minute, so a page-load burst
up to the capacity is admitted and the long-run rate holds.
- An over-budget request answers 429 `{"error": "rate limited"}` with a
`Retry-After` header giving the seconds until a token is available
(rounded up, never zero).
- Loopback addresses (127.0.0.0/8 and ::1) are exempt while
`web.api_localhost_exempt` is true (the default).
- Exempt routes, which never consult a bucket: `/metrics` and `/api/health`
(a Prometheus scrape must never see 429) and `/api/queries/live` (one
long-lived stream must not drain its address's bucket; it is bounded by
the SSE connection cap instead).
- The limiter tracks at most 4096 addresses. When the table is full and no
slot is reclaimable, requests from unknown addresses are refused with 429.
## Live query stream (SSE)
`GET /api/queries/live` is server-sent events over chunked transfer,
`Content-Type: text/event-stream`, `Cache-Control: no-store`.
- The stream opens with `retry: 3000`, so a browser `EventSource` reconnects
on its own after a drop.
- Each query is one frame: `event: query` and a single `data:` line of JSON.
The payload carries the `GET /api/queries` row fields minus `id` (a live
entry precedes persistence): `ts`, `domain`, `client_ip`, `qtype`,
`blocked`, `block_reason`, `response_time_us`, `cache_hit`, `upstream`.
- A `: ping` comment heartbeat goes out after 15 s of quiet, keeping
middleboxes from reaping the idle connection.
- Each subscriber buffers up to 64 entries. A client too slow for the query
rate overflows its buffer and the server ends the stream cleanly after
delivering what the buffer held — queries are never held back for a slow
reader. There is no gap marker: on reconnect, re-sync through
`GET /api/queries`, which has the missed rows.
- Connections per client address are capped at
`web.sse_max_connections_per_ip` (default 3); over the cap is a 429. The
cap binds loopback too. The server holds at most 32 concurrent streams in
total; when all slots are taken, the answer is a 503.
## Operations
Auth `open` means no session required; `session` means a valid session
cookie is required whenever a password is set. Rate limit `counted` spends a
token; `exempt` never consults the limiter.
| Method | Path | Auth | Rate limit | Purpose |
|---|---|---|---|---|
| GET | `/metrics` | open | exempt | Prometheus metrics |
| GET | `/api/health` | open | exempt | Health rollup |
| GET | `/api/version` | open | counted | Build and uptime |
| GET | `/api/openapi.yaml` | open | counted | This API's OpenAPI document |
| POST | `/api/auth/login` | open | counted | Log in |
| POST | `/api/auth/logout` | session | counted | Log out |
| GET | `/api/queries` | session | counted | Query log page |
| GET | `/api/queries/live` | session | exempt | Live query stream (server-sent events) |
| GET | `/api/stats` | session | counted | Totals for a period |
| GET | `/api/stats/timeseries` | session | counted | Bucketed counts for a period |
| GET | `/api/lookup` | session | counted | Explain a domain |
| GET | `/api/upstream/health` | session | counted | Upstream pool health |
| GET | `/api/groups` | session | counted | List groups |
| POST | `/api/groups` | session | counted | Create a group |
| GET | `/api/groups/{id}` | session | counted | Read a group |
| PUT | `/api/groups/{id}` | session | counted | Update a group |
| DELETE | `/api/groups/{id}` | session | counted | Delete a group |
| GET | `/api/groups/{id}/sources` | session | counted | Blocklist sources assigned to a group |
| PUT | `/api/groups/{id}/sources` | session | counted | Replace the assignment |
| GET | `/api/blocklists` | session | counted | List blocklist sources |
| POST | `/api/blocklists` | session | counted | Add a blocklist source |
| POST | `/api/blocklists/update` | session | counted | Refresh every enabled source now |
| GET | `/api/blocklists/{id}` | session | counted | Read a blocklist source |
| PUT | `/api/blocklists/{id}` | session | counted | Update a blocklist source |
| DELETE | `/api/blocklists/{id}` | session | counted | Delete a blocklist source |
| GET | `/api/rules` | session | counted | List rules |
| POST | `/api/rules` | session | counted | Create a rule |
| GET | `/api/rules/{id}` | session | counted | Read a rule |
| PUT | `/api/rules/{id}` | session | counted | Update a rule |
| DELETE | `/api/rules/{id}` | session | counted | Delete a rule |
| GET | `/api/local-records` | session | counted | List local DNS records |
| POST | `/api/local-records` | session | counted | Create a local record |
| GET | `/api/local-records/{id}` | session | counted | Read a local record |
| PUT | `/api/local-records/{id}` | session | counted | Update a local record |
| DELETE | `/api/local-records/{id}` | session | counted | Delete a local record |
| GET | `/api/forward-zones` | session | counted | List forward zones |
| POST | `/api/forward-zones` | session | counted | Create a forward zone |
| GET | `/api/forward-zones/{id}` | session | counted | Read a forward zone |
| PUT | `/api/forward-zones/{id}` | session | counted | Update a forward zone |
| DELETE | `/api/forward-zones/{id}` | session | counted | Delete a forward zone |
| GET | `/api/clients` | session | counted | List clients |
| GET | `/api/clients/{id}` | session | counted | Read a client |
| PUT | `/api/clients/{id}` | session | counted | Rename or regroup a client |
| DELETE | `/api/clients/{id}` | session | counted | Forget a client |
| GET | `/api/client-prefixes` | session | counted | List client prefixes |
| PUT | `/api/client-prefixes` | session | counted | Replace the prefix table |
| GET | `/api/upstreams` | session | counted | List upstream resolvers |
| POST | `/api/upstreams` | session | counted | Add an upstream |
| GET | `/api/upstreams/{id}` | session | counted | Read an upstream |
| PUT | `/api/upstreams/{id}` | session | counted | Update an upstream |
| DELETE | `/api/upstreams/{id}` | session | counted | Delete an upstream |
| GET | `/api/pause` | session | counted | Read the pause state |
| POST | `/api/pause` | session | counted | Pause or resume blocking |
| GET | `/api/settings` | session | counted | Read the scalar settings |
| PUT | `/api/settings` | session | counted | Update settings |
| POST | `/api/certs/reload` | session | counted | Reload the TLS certificates from disk |
There is no `POST /api/clients`: client rows come from DNS activity or
import, never from the API.
## Schemas
Request and response schemas for every operation live in the OpenAPI
document: `src/web/openapi.yaml` in the repository, or
`GET /api/openapi.yaml` from a running server.
+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.
+451
View File
@@ -0,0 +1,451 @@
# Configuration reference
Every section, field and collection nxdns accepts, with its type, default,
unit, validation rule and the subsystem that consumes it. Source of truth:
`src/config/model.zig` (the model and defaults), `src/config/validate.zig`
(the rules), `src/config/{bootstrap,import,export}.zig` (the lifecycle).
## How configuration works
The database is the truth; the file is a seed.
- On `nxdns run`, the configuration file (default `/etc/nxdns/config.zon`,
overridable with `--config`) is imported into `config.db` **once**: only
when the file exists and the database has never been configured. On every
later start the file is ignored and the database is used as it is
(`src/config/bootstrap.zig`). A file that exists but is unreadable,
unparseable or invalid fails the start — nxdns never falls back to silent
defaults over a file the operator wrote.
- After the seed, changes are made through the web API (or `nxdns import
--force`), never by editing the file. Editing the file after first boot has
no effect.
- `nxdns export` renders the database back as canonical ZON: fixed two-line
header, every default emitted, deterministic ordering, no timestamps. The
round trip `export` → `import` → `export` is byte-identical. With `--out
FILE` the write is atomic and the file is created mode 0600, because the
export carries `web.password_hash`.
- `nxdns import FILE` replaces the whole database content in one transaction.
Without `--force` it refuses a database that already has content
(`error.DatabaseNotEmpty`); a failed import leaves the database untouched.
- `nxdns check` validates without writing. Source selection order: an
explicit `--config FILE` wins; otherwise `config.db` in the data directory
if it exists; otherwise the default config file path if it exists;
otherwise "nothing to check" (exit 2). `check` also verifies TLS
certificate/key readability and, from the command line, probes each enabled
upstream with a real query.
Absent fields keep their defaults — in the file and in the database. A
settings key stored in the database that the running binary does not know is
warned about and ignored, never an error, so a downgrade cannot brick a
config database.
## What is not in the file
Storage paths are process arguments, not configuration:
- `--data-dir DIR` (default `/var/lib/nxdns`) holds `config.db` and
`querylog.db`. The directory is created mode 0700; both databases and their
WAL sidecars are forced to mode 0600.
- `--config FILE` (default `/etc/nxdns/config.zon`) names the seed file.
- `--web-dev DIR` (`run` only) serves the web interface from a directory
instead of the embedded assets.
## File format
The file is ZON: a top-level anonymous struct whose fields are the sections
and collections below. Enum values are written as ZON enum literals
(`.level = .err`, `.response = .nxdomain`). Strings are double-quoted. The
file may be at most 4 MiB (`ConfigTooLarge` beyond that). A syntax error is
reported with its line and column.
One serialization quirk: the log level `error` is the Zig keyword `error`,
so the ZON/model tag is `.err` while the database stores the operator-facing
word `"error"`. `err` is not accepted as database text, and `error` is not a
ZON tag — the file says `.err`, the settings API says `"error"`.
## Scalar sections
The "Key" column is the settings key as stored in the database
(`section.field`); in the file the same field lives inside its section block,
e.g. `.dns = .{ .port = 53 }`.
### upstream
Timeouts for talking to upstream resolvers.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `upstream.read_timeout_ms` | u32 | 3000 | ms | 100120000 | read deadline on conditional-forward-zone exchanges (`src/server/handler.zig` via `app.zig`) |
| `upstream.total_timeout_ms` | u32 | 5000 | ms | 100120000, and at least `read_timeout_ms` | per-query budget of the upstream pool (`src/upstream/pool.zig`) — the whole attempt including the connect; also the `nxdns check` probe deadline |
### dns
The plain DNS listener (UDP + TCP).
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `dns.bind_ipv4` | string | `"0.0.0.0"` | IP address | must parse as an IPv4 address | UDP/TCP listener bind (`src/app.zig`) |
| `dns.bind_ipv6` | string | `"::"` | IP address | must parse as an IPv6 address | UDP/TCP listener bind (`src/app.zig`) |
| `dns.port` | u16 | 53 | port | 165535 | UDP/TCP listener port |
| `dns.rate_limit` | u32 | 1000 | queries per window | at least 1 | per-client DNS rate limiter (`src/server/rate_limiter.zig`) |
| `dns.rate_window_seconds` | u32 | 60 | seconds | 13600 | window of the same limiter |
### blocking
What a blocked query gets back.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `blocking.response` | enum `.zero` \| `.nxdomain` | `.zero` | — | one of the two tags | blocked-response synthesis (`src/filter/response.zig`): `.zero` answers 0.0.0.0 / `::`, `.nxdomain` answers NXDOMAIN |
| `blocking.ttl` | u32 | 5 | seconds | at most 86400 (0 allowed) | TTL on the synthesized block answer |
### cache
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `cache.size` | u32 | 10000 | entries | none; `0` disables caching (put short-circuits — `src/cache/dns_cache.zig` test "a cache of zero entries stores nothing") | DNS answer cache capacity (`src/cache/dns_cache.zig`) |
| `cache.negative_ttl_max` | u32 | 3600 | seconds | at most 86400 | cap on cached negative answers; 0 disables negative caching |
### web
The web interface and REST API.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `web.enabled` | bool | true | — | — | gates the whole web stack: server, sessions, SSE hub, API limiter (`src/app.zig`) |
| `web.bind` | string | `"0.0.0.0"` | IP address | must parse as an IP address | web listener bind (`src/web/server.zig`) |
| `web.port` | u16 | 8080 | port | 165535 | web listener port |
| `web.password` | string | `""` | — | must not be set together with `web.password_hash` | operator input only — see "Authentication" below; never stored, never a settings key |
| `web.password_hash` | string | `""` | — | — | argon2id PHC string verified at login (`src/web/auth.zig`); `""` disables authentication |
| `web.session_ttl_hours` | u16 | 24 | hours | at least 1 | session expiry and cookie Max-Age (`src/web/auth.zig`) |
| `web.api_rate_limit_per_min` | u32 | 300 | requests per minute | at least 1 | API token-bucket limiter (`src/web/api_limiter.zig`) |
| `web.api_localhost_exempt` | bool | true | — | — | loopback requests skip the API limiter |
| `web.sse_max_connections_per_ip` | u16 | 3 | connections | at least 1 | cap on concurrent SSE streams per client IP |
### doh_server
The DNS-over-HTTPS listener (server side, for clients on the LAN).
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `doh_server.enabled` | bool | false | — | — | gates the DoH listener (`src/server/doh_server.zig`) |
| `doh_server.bind` | string | `"0.0.0.0"` | IP address | must parse as an IP address | DoH listener bind |
| `doh_server.port` | u16 | 443 | port | 165535 | DoH listener port |
| `doh_server.cert_path` | string | `"/etc/nxdns/cert.pem"` | path | non-empty when enabled | certificate loaded into the hot-reloading `CertStore`; readability is checked by `nxdns check`, not the validator |
| `doh_server.key_path` | string | `"/etc/nxdns/key.pem"` | path | non-empty when enabled | private key for the same; `nxdns check` warns when it is readable beyond its owner |
### dot_server
The DNS-over-TLS listener. Same shape as `doh_server`; only the default port
differs.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `dot_server.enabled` | bool | false | — | — | gates the DoT listener (`src/server/dot_server.zig`) |
| `dot_server.bind` | string | `"0.0.0.0"` | IP address | must parse as an IP address | DoT listener bind |
| `dot_server.port` | u16 | 853 | port | 165535 | DoT listener port |
| `dot_server.cert_path` | string | `"/etc/nxdns/cert.pem"` | path | non-empty when enabled | certificate, shared `CertStore` with hot reload |
| `dot_server.key_path` | string | `"/etc/nxdns/key.pem"` | path | non-empty when enabled | private key for the same |
### edns
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `edns.ecs_mode` | enum `.strip` \| `.forward` | `.strip` | — | one of the two tags | EDNS Client Subnet handling in the query path (`src/server/handler.zig`, `src/dns/edns.zig`): `.strip` removes the client subnet before forwarding, `.forward` passes it through |
### logging
Process log and query log behavior.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `logging.level` | enum `.err` \| `.warn` \| `.info` \| `.debug` | `.info` | — | one of the four tags; stored as `"error"`/`"warn"`/`"info"`/`"debug"` | log threshold (`src/platform/logging.zig`) |
| `logging.retention_days` | u16 | 30 | days | at least 1 | query-log pruning cutoff (`src/storage/retention.zig`) and the client tracker's last-seen cutoff (`src/server/clients.zig`) |
| `logging.query_log_buffer_max` | u32 | 10000 | entries | at least 1 | in-memory query-log ring size and backpressure cap (`src/storage/logger.zig`) |
| `logging.hide_domains` | bool | false | — | — | query log stores a hidden marker instead of the domain |
| `logging.hide_client_ips` | bool | false | — | — | query log stores a hidden marker instead of the client IP |
| `logging.output` | enum `.stderr` \| `.syslog` \| `.file` | `.stderr` | — | one of the three tags | log sink selection (`src/platform/logging.zig`); `.stderr` and `.syslog` both write to stderr (journald captures it), `.file` rotates |
| `logging.file_path` | string | `"/var/log/nxdns/nxdns.log"` | path | when `output` is `.file`: non-empty absolute path | rotating log file; its directory also feeds the disk monitor. The binary does not create the directory |
| `logging.max_size_mb` | u32 | 50 | MiB | at least 1 | rotation trigger for the log file |
| `logging.max_files` | u8 | 5 | files | at least 1 | rotated generations kept |
### disk
Free-space thresholds for the data directory. When free space falls below
them, the query-log writer, client tracker and blocklist scheduler are
throttled (`src/storage/disk_monitor.zig`).
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `disk.min_free_mb` | u32 | 200 | MiB | at least 1, and not above `warn_free_mb` | `critical` threshold |
| `disk.warn_free_mb` | u32 | 500 | MiB | at least 1 | `warn` threshold |
### blocklist_update
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `blocklist_update.enabled` | bool | true | — | — | blocklist refresh scheduler (`src/filter/manager.zig`); when false only the startup pass runs |
| `blocklist_update.interval_hours` | u16 | 24 | hours | at least 1 | sleep between refresh passes and the per-source staleness test |
## Collections
Collections are ZON lists of structs. Fields without a default are required.
Runtime columns (first/last seen timestamps, per-source download counters)
are deliberately not part of the model: import sets timestamps to the import
time, and export omits them, which is what keeps the round trip byte-stable.
### groups
Client groups. A group named `default` is **required**; every client not
assigned elsewhere lands in it, and import guarantees it keeps database id 1.
| Field | Type | Default | Validation |
|---|---|---|---|
| `name` | string | required | non-empty, unique |
| `safe_search` | bool | false | — |
Consumed by the filter engine (`src/filter/matcher.zig`); `safe_search`
triggers the safe-search rewrite in the query path.
### upstreams
Upstream resolvers. **At least one enabled upstream is required.**
| Field | Type | Default | Validation |
|---|---|---|---|
| `url` | string | required | `https://` (DoH) or `tls://` (DoT) endpoint; unique |
| `priority` | i32 | 100 | — (lower is tried first) |
| `enabled` | bool | true | — |
| `tls_name` | string | `""` | DoT only — a `tls_name` on an `https://` upstream is an error; must be a valid domain name |
Consumed by the upstream pool (`src/upstream/pool.zig`): entries are sorted
by ascending priority and tried in order with failover. `tls_name` sets SNI
and the certificate verification name for a `tls://` upstream written as an
IP literal; empty means "verify by the URL host"
(`src/upstream/dot_client.zig`).
### clients
Known clients with a fixed group assignment.
| Field | Type | Default | Validation |
|---|---|---|---|
| `ip` | string | required | IP address; unique after canonicalization (`FD00::1` and `fd00:0:0:0:0:0:0:1` collide) |
| `name` | string | `""` | — (display only, never read by the resolver) |
| `group` | string | `"default"` | must name a declared group |
Consumed by the filter engine's exact IP → group lookup
(`src/filter/matcher.zig`).
### client_prefixes
Group assignment by CIDR prefix, for clients without an exact entry.
| Field | Type | Default | Validation |
|---|---|---|---|
| `prefix` | string | required | CIDR prefix (`192.168.2.0/24`, `fd00:abcd::/48`); unique after canonicalization |
| `group` | string | `"default"` | must name a declared group |
| `priority` | i32 | 100 | — (ties on match are broken by lower priority) |
Consumed by the filter engine's longest-prefix match
(`src/filter/matcher.zig`).
### blocklist_sources
Downloadable blocklists.
| Field | Type | Default | Validation |
|---|---|---|---|
| `url` | string | required | `http://` or `https://` URL with a host; unique |
| `name` | string | required | non-empty |
| `enabled` | bool | true | — |
| `is_suggested` | bool | false | — (web UI hint only, never read by the resolver) |
Consumed by the blocklist manager (`src/filter/manager.zig`): downloaded by
the fetcher, compiled into domain sets; a disabled source is neither
downloaded nor loaded.
### group_sources
Which groups consult which blocklist sources.
| Field | Type | Default | Validation |
|---|---|---|---|
| `group` | string | required | must name a declared group |
| `source_url` | string | required | must name a declared blocklist source's `url`; the (group, source_url) pair is unique |
Consumed by the filter engine when assembling each group's compiled domain
sets (`src/filter/matcher.zig`). A link to a disabled source is silently
skipped.
### rules
Per-group allow/block overrides, checked before the blocklists.
| Field | Type | Default | Validation |
|---|---|---|---|
| `group` | string | required | must name a declared group |
| `pattern` | string | required | see below |
| `kind` | enum `.exact` \| `.wildcard` | required | — |
| `action` | enum `.allow` \| `.block` | required | — |
Pattern rules: an `.exact` pattern is a plain domain name and may not contain
`*`; a `.wildcard` pattern must contain at least one label that is exactly
`*` (`*.tracker.example`, or `*` alone), and every other label must be a
legal DNS label. `ads*.example` is not a valid wildcard.
Consumed by the filter engine's rule sets (`src/filter/rules.zig`).
### local_records
Local DNS answers, served without touching any upstream.
| Field | Type | Default | Validation |
|---|---|---|---|
| `name` | string | required | valid domain name |
| `rtype` | enum `.a` \| `.aaaa` \| `.cname` | required | stored as `A`/`AAAA`/`CNAME` |
| `value` | string | required | IPv4 address for `.a`, IPv6 for `.aaaa`, domain name for `.cname` |
| `ttl` | u32 | 300 | 1604800 seconds |
The (name, rtype, value) triple is unique. Consumed by the local records
table in the query path (`src/local/records.zig`).
### forward_zones
Zones resolved by a specific resolver instead of the configured upstreams —
for LAN or corporate domains.
| Field | Type | Default | Validation |
|---|---|---|---|
| `zone` | string | required | valid domain name; unique |
| `resolver` | string | required | `udp://IP:port` or `tcp://IP:port`; the host must be an IP literal and the port is mandatory |
The resolver host must be an IP literal because resolving the resolver's own
name would be a bootstrap problem. Matching is longest suffix
(`src/local/forward_zones.zig`); the exchange is UDP-then-TCP
(`src/local/forward_client.zig`) with `upstream.read_timeout_ms` as the read
deadline.
## Authentication: web.password vs web.password_hash
Exactly one of the two may be set; setting both is refused
(`PasswordAndHashBothSet` — ambiguity in a security setting).
- `web.password` is operator input only. At import time it is hashed with
argon2id (OWASP parameters: t=2, m=19 MiB, p=1, PHC encoding) into
`web.password_hash` and discarded. It is never stored — there is no
`web.password` settings row, and `nxdns export` always writes
`.password = ""`.
- `web.password_hash` is the stored argon2id PHC string. Supplying it
directly (for example from a previous export) is how a backup restores
authentication without knowing the password.
- Both empty disables web authentication entirely.
Because the export carries the hash and re-importing an exported file takes
the "password is empty" branch, the export/import round trip preserves the
hash byte-for-byte.
## Minimal working example
The smallest file that passes validation: a `default` group and one enabled
upstream. Everything else keeps its default.
```zon
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://dns.quad9.net/dns-query" } },
}
```
## Fuller annotated example
```zon
.{
// Plain DNS on the standard port, rate-limited per client.
.dns = .{
.bind_ipv4 = "0.0.0.0",
.bind_ipv6 = "::",
.port = 53,
.rate_limit = 1000,
.rate_window_seconds = 60,
},
// Blocked queries answer 0.0.0.0 / :: with a 5 second TTL.
.blocking = .{ .response = .zero, .ttl = 5 },
.cache = .{ .size = 10000, .negative_ttl_max = 3600 },
// Web UI on 8080. The password is hashed at import and never stored;
// leave .password_hash out when setting .password (they are exclusive).
.web = .{
.enabled = true,
.port = 8080,
.password = "correct horse battery staple",
.session_ttl_hours = 24,
},
// Serve DoT to the LAN. The key file should be mode 0600.
.dot_server = .{
.enabled = true,
.port = 853,
.cert_path = "/etc/nxdns/cert.pem",
.key_path = "/etc/nxdns/key.pem",
},
// Strip EDNS Client Subnet before forwarding (the default).
.edns = .{ .ecs_mode = .strip },
// ".err" in the file; the settings API shows it as "error".
.logging = .{ .level = .err, .retention_days = 14 },
.blocklist_update = .{ .enabled = true, .interval_hours = 24 },
// "default" is mandatory. Additional groups get their own rules,
// blocklists and safe-search flag.
.groups = .{
.{ .name = "default" },
.{ .name = "kids", .safe_search = true },
},
// Lower priority is tried first; the second entry is a failover.
// tls_name is needed when a tls:// upstream is written as an IP
// literal, so certificate verification has a DNS name to match.
.upstreams = .{
.{ .url = "https://dns.quad9.net/dns-query", .priority = 10 },
.{ .url = "tls://9.9.9.9:853", .priority = 20, .tls_name = "dns.quad9.net" },
},
// Exact client assignments win over prefixes.
.clients = .{
.{ .ip = "192.168.1.20", .name = "tablet", .group = "kids" },
},
.client_prefixes = .{
.{ .prefix = "192.168.2.0/24", .group = "kids" },
},
.blocklist_sources = .{
.{ .url = "https://lists.example/ads.txt", .name = "ads" },
},
.group_sources = .{
.{ .group = "kids", .source_url = "https://lists.example/ads.txt" },
},
// Overrides beat blocklists. Wildcards need a label that is exactly "*".
.rules = .{
.{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow },
.{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block },
},
// Local names, answered without any upstream.
.local_records = .{
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" },
.{ .name = "www.lan", .rtype = .cname, .value = "nas.lan" },
},
// Everything under corp.lan goes to the LAN resolver directly.
.forward_zones = .{
.{ .zone = "corp.lan", .resolver = "udp://192.168.1.1:53" },
},
}
```
+7
View File
@@ -0,0 +1,7 @@
//! Embeds the documentation files the drift tests guard. Module root for the
//! `docs_files` anonymous import (test builds only) — @embedFile paths resolve
//! relative to this file.
pub const api_md = @embedFile("api.md");
pub const config_reference_md = @embedFile("config-reference.md");
pub const operator_md = @embedFile("operator.md");
+418
View File
@@ -0,0 +1,418 @@
# Operating nxdns
How to install, configure, back up, upgrade and troubleshoot an nxdns server.
For the meaning of every configuration field, see
[config-reference.md](config-reference.md); for the HTTP API,
[api.md](api.md).
## Install: systemd
nxdns ships as one static musl binary. Build it (see
[Building](#building-the-binary)) or take it from CI, then:
```sh
# 1. The binary.
install -m 0755 nxdns /usr/local/bin/nxdns
# 2. The service user. Static, not DynamicUser: the TLS key for DoH/DoT
# must be chown-able to a stable uid.
install -m 0644 deploy/systemd/sysusers.conf /usr/lib/sysusers.d/nxdns.conf
systemd-sysusers
# 3. The unit.
install -m 0644 deploy/systemd/nxdns.service /etc/systemd/system/nxdns.service
systemctl daemon-reload
```
Do not create directories by hand. The unit's `StateDirectory=nxdns`,
`LogsDirectory=nxdns` and `ConfigurationDirectory=nxdns` make systemd create
`/var/lib/nxdns` (mode 0700, owned by `nxdns`), `/var/log/nxdns` and
`/etc/nxdns` on first start.
Write a seed configuration to `/etc/nxdns/config.zon`. The minimum that
starts is one group named `default` and one enabled upstream:
```zon
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
.web = .{ .password = "choose-a-real-password" },
}
```
Then:
```sh
systemctl enable --now nxdns
journalctl -u nxdns -f
```
nxdns logs to stderr by default and systemd captures that into the journal;
nothing else needs configuring for logs. Port 53 needs
`CAP_NET_BIND_SERVICE`, which the unit grants via `AmbientCapabilities`.
If port 53 is already taken, see
[Port 53 conflicts](#port-53-is-taken-systemd-resolved).
### Building the binary
Requires Zig 0.16.0 and Node.js 24 (for the web UI). From the repository
root:
```sh
(cd web && npm ci && npm run build)
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
```
This produces static binaries for both deploy targets:
- `zig-out/cross/x86_64-linux-musl/nxdns`
- `zig-out/cross/aarch64-linux-musl/nxdns`
### Raspberry Pi 5 recipe
The Pi 5 is aarch64. Build on any machine (the cross build needs no
toolchain beyond Zig itself), copy the binary over, then follow the systemd
steps above on the Pi:
```sh
(cd web && npm ci && npm run build)
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
scp zig-out/cross/aarch64-linux-musl/nxdns pi:/tmp/nxdns
scp deploy/systemd/nxdns.service deploy/systemd/sysusers.conf pi:/tmp/
# on the Pi, as root:
install -m 0755 /tmp/nxdns /usr/local/bin/nxdns
install -m 0644 /tmp/sysusers.conf /usr/lib/sysusers.d/nxdns.conf
systemd-sysusers
install -m 0644 /tmp/nxdns.service /etc/systemd/system/nxdns.service
systemctl daemon-reload
# write /etc/nxdns/config.zon, then:
systemctl enable --now nxdns
```
The binary is statically linked against musl; it has no runtime
dependencies on the Pi.
## Install: Docker
The image is built from a binary you compile first; the Dockerfile only
assembles the filesystem. From the repository root:
```sh
(cd web && npm ci && npm run build)
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
docker build -t nxdns -f deploy/docker/Dockerfile .
```
or, with compose (which runs the same build with the repository root as
context):
```sh
cd deploy/docker
docker compose build
```
The Dockerfile maps buildx's `TARGETARCH` onto the cross-target directory,
so `docker buildx build --platform linux/arm64` produces the aarch64 image
from the same `zig-out/cross` tree.
Before the first `docker compose up`, create the seed configuration the
compose file bind-mounts read-only at `/etc/nxdns`:
```sh
cd deploy/docker
mkdir -p etc-nxdns
$EDITOR etc-nxdns/config.zon # the same minimal seed as the systemd path
```
The seed must be readable by uid 65532, the fixed container user; the bind
mount is read-only, so the container cannot adjust permissions itself.
World-readable (0644) is fine when the seed carries no secret; if it holds
`web.password` or `web.password_hash` (a restored export), restrict it instead:
`chown 65532:65532 etc-nxdns/config.zon && chmod 0600 etc-nxdns/config.zon`.
Without a valid seed — at least a `default` group and one enabled
upstream — the container exits with code 2, because a fresh volume holds an
empty database and an empty database has no upstream to forward to.
The compose file publishes 53/udp, 53/tcp and 8080, keeps the data in a
named volume mounted at `/var/lib/nxdns`, and sets the per-network-namespace
sysctl `net.ipv4.ip_unprivileged_port_start=0` so the nonroot user
(uid 65532) can bind port 53. Uncomment the 443/853 port mappings when you
enable the DoH or DoT listener.
Do not point the host's `/etc/resolv.conf` at the nxdns container. The
container resolves its upstream DoH/DoT hostnames through the host's DNS
configuration; pointing that at nxdns itself makes the container's own
lookups depend on the service they are trying to start.
### Publishing the image to a private registry
There is deliberately no registry push in CI — credentials and registry
choice are an infrastructure decision, not this repository's. To publish
manually, log in to your registry, tag the local image with the registry's
name, and push: `docker login <registry>`, then
`docker tag nxdns <registry>/<owner>/nxdns:<tag>`, then
`docker push <registry>/<owner>/nxdns:<tag>`. The same works for a
self-hosted Gitea registry such as git.mial.net.
## First boot and configuration semantics
The ZON file at `/etc/nxdns/config.zon` (or `--config`) seeds the database
exactly once:
- **No file:** normal steady state; the database is used as it is.
- **File present, database empty:** the file is imported. A file that is
unreadable, unparseable or invalid is an error (exit 2, every problem
printed) — nxdns never falls back to silent defaults over a file you
wrote.
- **File present, database already configured:** the file is ignored. The
database is the truth from the first successful seed onward.
After the first boot, editing `config.zon` changes nothing. Change the
configuration through the web UI, the REST API, or the export→edit→import
cycle:
```sh
nxdns export --out config-backup.zon
$EDITOR config-backup.zon
systemctl stop nxdns
nxdns import config-backup.zon --force
systemctl start nxdns
```
`import` without `--force` refuses a database that already has content
(exit 2), so a plain `import` can never clobber a configured server by
accident.
## Authentication setup
Set `web.password` in the seed file (or in a file you `import`). At import
time it is hashed with argon2id into `web.password_hash` and discarded; the
plaintext is never stored anywhere. `nxdns export` always writes
`.password = ""` and carries the hash instead, so an exported file
re-imports without knowing the password. Setting both `password` and
`password_hash` in one file is an error (exit 2). To change the password,
export, set `.password` to the new value, clear `.password_hash` to `""`,
and import with `--force`.
## TLS for the DoH/DoT listeners
Both listeners are disabled by default. To enable one, set
`doh_server.enabled` / `dot_server.enabled` and point `cert_path` and
`key_path` at a PEM certificate chain and key, conventionally under
`/etc/nxdns`. Ownership depends on how you deploy.
Under systemd, the service runs as the `nxdns` user:
```sh
chown nxdns:nxdns /etc/nxdns/cert.pem /etc/nxdns/key.pem
chmod 0644 /etc/nxdns/cert.pem
chmod 0600 /etc/nxdns/key.pem
```
Under Docker, the container runs as uid 65532 (fixed in the image) and
`/etc/nxdns` is a read-only bind mount, so the container cannot fix
permissions itself — the host-side files must already be readable by that
uid. It has no name on the host or in the scratch image, so chown it
numerically:
```sh
cd deploy/docker
chown 65532:65532 etc-nxdns/cert.pem etc-nxdns/key.pem
chmod 0644 etc-nxdns/cert.pem
chmod 0600 etc-nxdns/key.pem
```
The key must be readable by the user nxdns runs as and only by its owner:
`nxdns check` prints a WARN for a key with any group or other permission
bits, and a FAIL (exit 2) for a cert or key the user cannot read. A
certificate that fails to load at boot while its listener is enabled exits 2.
Renewals need no restart. A watcher polls both files every 30 seconds and
swaps the new pair in atomically; in-flight connections finish on the old
certificate. To pick up a renewal immediately — for example from a certbot
deploy hook — call `POST /api/certs/reload` (session-authenticated; see
[api.md](api.md)). A reload that fails to parse leaves the old certificate
serving and reports the error.
## Backup, restore and upgrades
**Backup** is one command against a stopped or running server:
```sh
nxdns export --out /some/backup/nxdns-config.zon
```
The write is atomic (temp file + rename) and mode 0600, because the file
carries `web.password_hash` — treat backups as secrets. Without `--out` the
export goes to stdout, where file permissions are your redirect's problem.
The query log is deliberately not part of the backup; it is expendable
history.
**Restore** onto a fresh data directory or over an existing one:
```sh
nxdns import /some/backup/nxdns-config.zon --force
```
**Upgrades:** install the new binary, restart the service. Schema
migrations run automatically at startup (and before `check`, `export` and
`import`), so a database one schema version behind is upgraded in place.
There is no downgrade path; take an export before upgrading.
## Data directory layout
Everything lives under the data directory (default `/var/lib/nxdns`,
override with `--data-dir`), mode 0700:
| Path | What it is |
| --- | --- |
| `config.db` (+ `-wal`, `-shm`) | The configuration database — the single source of truth, including `web.password_hash`. Mode 0600. Back it up via `nxdns export`. |
| `querylog.db` (+ `-wal`, `-shm`) | The query log. Mode 0600 — it records every domain every client asked for. Expendable: if it is missing or unusable it is recreated empty. |
| `blocklists/` | Compiled blocklist snapshots, two files per source: `<id>.list` (exact domains) and `<id>.wild` (wildcards). `.raw.tmp` / `.list.tmp` / `.wild.tmp` files are transient refresh state. |
## CLI reference
```
nxdns <command> [options]
```
Flags take both spellings: `--flag value` and `--flag=value`.
### `run`
Serves DNS until SIGINT or SIGTERM.
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory (default `/var/lib/nxdns`). Created at 0700 if missing. |
| `--config FILE` | Seed configuration file (default `/etc/nxdns/config.zon`). Read only when the database is empty. |
| `--web-dev DIR` | Serve the web interface from DIR instead of the embedded assets, with no cache headers. Development only. |
### `check`
Validates the configuration and probes the upstreams. Exit 0 when clean,
2 when it found problems, and it always reports every problem, not just the
first. What it checks, in order:
1. Which source to check (see [source selection](#check-source-selection)).
2. Full validation — the same rules `import` enforces.
3. For each enabled DoH/DoT listener: cert and key are readable (FAIL if
not), key permissions are owner-only (WARN if not).
4. A live probe: one real A query for `example.com` through every enabled
upstream, using the same failover machinery the server uses. A FAIL line
names the upstream and the concrete cause. This probe leaves the machine,
so `check` needs network access to pass.
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory to look for `config.db` in. |
| `--config FILE` | Check this file instead of the database. |
#### check source selection
- `--config FILE` given explicitly: check that file, nothing else.
- Otherwise, if `<data-dir>/config.db` exists: check the database — the
right default, since the database is the truth on a configured server.
- Otherwise, if the default config file exists: check it.
- Otherwise: "nothing to check", exit 2.
The first line of output always names which source was checked.
### `export`
Writes the configuration as ZON to stdout, or atomically at mode 0600 to
`--out FILE`. `--out` paths are relative to the shell's working directory.
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory holding `config.db`. |
| `--out FILE` | Write to FILE instead of stdout. |
### `import FILE`
Validates FILE and replaces the configuration with it. Prints every
validation problem on failure. Refuses a non-empty database without
`--force`.
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory holding `config.db` (created if missing). |
| `--force` | Replace a database that already has content. |
### `version`
Prints the nxdns version, git commit and Zig version.
### `help`
Prints usage. `--help` and `-h` do the same.
## Exit codes
| Code | Meaning |
| --- | --- |
| 0 | Success. |
| 1 | Runtime failure — I/O, database, out of memory. |
| 2 | A configuration problem the operator can fix, or a `check` that found one. |
| 64 | Usage error — unknown command or flag, missing argument. |
## Troubleshooting
### The service exits with code 2
Exit 2 always means a configuration you can fix; `nxdns run` prints the
cause and suggests `nxdns check`, which shows the full list. The usual
causes:
- Empty database and no seed file, or a seed file with no `default` group
or no enabled upstream. On a fresh install this means `config.zon` is
missing, in the wrong place, or invalid.
- A seed or imported file that fails validation — every problem is printed
with its field name.
- `dns.bind_ipv4` / `dns.bind_ipv6` is not an IP address, or a rate limit
is zero (possible only in a hand-edited database; `import` refuses both).
- A DoH/DoT listener is enabled but its certificate or key is unreadable or
unparseable at boot.
- `import` into a non-empty database without `--force`.
### Port 53 is taken (systemd-resolved)
On most systemd distributions, `systemd-resolved` owns a stub listener on
`127.0.0.53:53`, and on some setups binds `0.0.0.0:53`. Turn the stub off
and keep resolved for the host's own lookups:
```sh
mkdir -p /etc/systemd/resolved.conf.d
printf '[Resolve]\nDNSStubListener=no\n' > /etc/systemd/resolved.conf.d/nxdns.conf
systemctl restart systemd-resolved
```
If `/etc/resolv.conf` is a symlink to the stub
(`/run/systemd/resolve/stub-resolv.conf`), repoint it at
`/run/systemd/resolve/resolv.conf` so the host still resolves. Do not point
the host running nxdns at nxdns itself if that host is where nxdns resolves
its upstream DoH/DoT hostnames — that is a bootstrap cycle.
### Disk is filling up
The disk monitor samples free space and database sizes once a minute and
classifies the state against `disk.warn_free_mb` and `disk.min_free_mb`.
Below the warn threshold it logs the transition; below `min_free_mb` it
gates every non-essential write: the query logger holds its batches, the
client tracker stops persisting, and the blocklist scheduler skips its
refresh passes. DNS keeps answering throughout — resolution never degrades
because the disk is full. The state and the size gauges are visible on
`/metrics` and in the web UI. Recover space (lower
`logging.retention_days`, or delete `querylog.db` with the service
stopped) and writes resume on the next sample.
### Blocklists are not filtering
Serving starts even when no blocklist snapshot loads — a household loses
more from DNS that refuses to start than from a window of unfiltered
answers. The startup journal line says either `blocklist generation N` or
`unfiltered (no blocklist snapshot)`. If it says unfiltered, check the
journal for the download or compile warning that preceded it.
+273
View File
@@ -0,0 +1,273 @@
# Milestone 11: packaging, ops and docs (PLAN Phase 10)
Goal: systemd unit, Dockerfile + compose, and the four docs (operator, architecture,
config-reference, API) — the documented deployment must work end-to-end; docs are
drift-guarded where a guard is cheap and honest.
## Rulings (binding)
1. **Layout.** `deploy/systemd/nxdns.service` + `deploy/systemd/sysusers.conf`;
`deploy/docker/{Dockerfile,compose.yaml,.dockerignore}`; `docs/{operator.md,
architecture.md,config-reference.md,api.md}`; `README.md` at the root (the repo has
none; a portfolio repo needs a front door — short: what, why, quickstart, doc links).
PLAN.md:236 sketches docs/ subdirectories; single files need no subdirectories.
2. **API docs = hand-written `docs/api.md` + drift test.** No renderer is vendored
(redoc/scalar are exactly the dependency liability AGENTS.md refuses), and a
build-time YAML parser for rendering is scope the yaml does not justify — the yaml
itself is already served unauthenticated at `GET /api/openapi.yaml` (routes.zig:49)
and is the exhaustive contract. `docs/api.md` gives human-readable orientation:
auth model (cookie session, login flow), rate limiting, error envelope, SSE
semantics, then one line per operation (method, path, auth, one-sentence purpose)
and a pointer to the yaml for schemas. A drift test asserts every served route
appears textually in api.md (mirror of openapi.zig:34's guard). This satisfies
m8 ruling 3's deferred "docs/api rendering" as the engineering call: rendered =
readable, guarded, in-repo; not = a vendored JS bundle.
3. **Docs drift guards.** New `src/docs_drift_test.zig` (ORCHESTRATOR-owned, written
after the doc sessions land): (a) every route in `router.routes` appears in
docs/api.md; (b) every settings key from `model.toSettings` (the 44 keys) appears
in docs/config-reference.md; (c) every CLI subcommand name appears in
docs/operator.md. Docs embedded via a `docs_files` anonymous import added in
build.zig (test_fixtures pattern, build.zig:61). Guards are textual-containment
only — cheap, zero false authority.
4. **systemd unit.** `Type=simple` (no forking, shutdown.zig:35 handles SIGTERM),
`ExecStart=/usr/local/bin/nxdns run`, stderr → journald (logging.zig:301 already
states this; `logging.output=stderr` stays the default). Static system user `nxdns`
via `deploy/systemd/sysusers.conf` (`u nxdns - "nxdns DNS sinkhole"`), NOT
DynamicUser — the TLS key must be chown-able to a stable uid ("TLS keys readable by
service user only", PLAN §19). `StateDirectory=nxdns` (0700 matches cli.zig:226),
`LogsDirectory=nxdns` (covers logging.output=file; the binary does not create the
directory, logging.zig:502), `ConfigurationDirectory=nxdns`.
`AmbientCapabilities=CAP_NET_BIND_SERVICE` + `CapabilityBoundingSet=` the same
(port 53; 443/853 covered by the same cap). Hardening: `NoNewPrivileges=yes`,
`ProtectSystem=strict`, `ProtectHome=yes`, `PrivateTmp=yes`, `PrivateDevices=yes`,
`ProtectKernelTunables/Modules/Logs=yes`, `ProtectControlGroups=yes`,
`ProtectClock=yes`, `ProtectHostname=yes`, `ProtectProc=invisible`,
`RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX`, `RestrictNamespaces=yes`,
`RestrictRealtime=yes`, `RestrictSUIDSGID=yes`, `LockPersonality=yes`,
`MemoryDenyWriteExecute=yes` (static Zig binary, no JIT), `UMask=0077`,
`SystemCallFilter=@system-service`, `SystemCallArchitectures=native`,
`Restart=on-failure`, `RestartSec=2`. No `ReadWritePaths` beyond what
StateDirectory/LogsDirectory grant. Validate with `systemd-analyze verify` if the
build host has it; report honestly if not.
5. **Docker.** Multi-stage: builder stage only stages `ca-certificates` (upstream
TLS verification rescans the system CA bundle, tls_client.zig:216 — a scratch
image without a bundle breaks every DoH/DoT upstream); final `FROM scratch` with
the static musl binary, `/etc/ssl/certs/ca-certificates.crt`, a nonroot numeric
`USER 65532:65532`, `VOLUME /var/lib/nxdns`, `EXPOSE 53/udp 53/tcp 8080 443 853`,
`ENTRYPOINT ["/nxdns"]`, `CMD ["run"]`. The binary is NOT built inside the
Dockerfile (the repo builds it with zig; the Dockerfile COPYes
`zig-out/cross/$TARGETARCH-…/nxdns` via a build arg or buildx TARGETARCH mapping —
keep it working for both arches). compose.yaml: ports 53:53/udp+tcp and 8080:8080
(443/853 commented), bind-mount `./etc-nxdns:/etc/nxdns:ro`, named volume for
`/var/lib/nxdns`, `sysctls: net.ipv4.ip_unprivileged_port_start=0` so the nonroot
user binds 53 (per-netns sysctl; documented), `restart: unless-stopped`. First
boot needs a seeded `/etc/nxdns/config.zon` with a `default` group + one enabled
upstream or the container exits 2 (bootstrap.zig:38, app.zig:93) — operator.md and
a compose comment both say so. Do NOT point the host's resolv.conf at nxdns
itself for the container's own lookups.
6. **CI.** One added job `docker` in ci.yml: after building the x86_64 exe
(ReleaseSafe, with the SPA dist like the cross job), `docker build` the image and
run a container smoke (seed a minimal config.zon; `nxdns version` + boot + one
`dig`-equivalent via the test client or `curl` on 8080/api/health; SIGTERM 0).
No registry push — no publish step exists anywhere in this repo or the infra
repo's CI, and registry credentials are an infra decision outside this repo.
Manual publishing to git.mial.net stays possible and is documented in operator.md
in one paragraph.
7. **Docs content contracts.**
- operator.md: install (systemd path and docker path, both complete), first boot +
config.zon seeding semantics (file seeds DB once, DB is truth thereafter,
bootstrap.zig:38), auth setup (`web.password` hashed on import, never stored,
export writes ""), TLS cert/key provisioning + 0600 expectations + the reload
API/watcher, backup/restore = `nxdns export`/`import --force` (+ the 0600 export
mode and why), upgrades (schema migration = install + restart, PLAN §20.11),
data-dir layout table, exit codes (0/1/2/64, cli.zig:35), CLI reference (all six
subcommands + flags), troubleshooting (exit 2 causes, `nxdns check` semantics
incl. source-selection order cli.zig:511, disk-full degradation, port 53
conflicts with systemd-resolved — include the disable recipe).
- architecture.md: module map (the src/ inventory), the purity rule (dns/, filter/,
local/, cache/ take bytes, no Io — AGENTS.md), std.Io injection + Threaded
backend, data flow for one query (listener → handler → filter/cache/local →
upstream → sink/logger), storage split (config.db truth / querylog.db expendable),
web stack (std.http over TLS optional, SPA embedded via web_assets, SSE), cert
hot-reload design (refcounted CertStore), failure-visibility doctrine (counters +
/metrics over log spam). Concise — a map, not a novel.
- config-reference.md: complete — every section/field with type, default, unit,
validation range, and which subsystem consumes it; collections with required
fields; DB-vs-file truth explanation; the `web.password`/`password_hash`
exclusivity; `logging.level=.err` serializes as "error" (model.zig:154). The
explorer inventory in this milestone's research is the skeleton; verify against
model.zig/validate.zig while writing, do not trust the summary blindly.
- api.md: per ruling 2.
- README.md: ≤120 lines; what nxdns is, feature list (honest, shipping features
only), quickstart (docker compose path), build-from-source (zig build, node for
the SPA), doc links, license note if a LICENSE exists (do not invent one).
8. **No new runtime code.** This milestone adds zero behavior to the binary. The only
src/ change is the orchestrator's docs_drift_test.zig + its build wiring. If a doc
session finds a bug while documenting, it REPORTS it (no fix); the orchestrator
triages.
9. **Pi 5 end-to-end**: the exit criterion runs on hardware this environment does not
have. The deliverable here is: both suites green, docker smoke green on x86_64,
`systemd-analyze verify` clean (or honestly reported unavailable), aarch64 binary
built and statically verified (existing cross job). The operator doc's Pi 5 recipe
is written to be executed by the user; the spec records this boundary explicitly.
## Sessions
U1, U3, U4, U5 parallel; U2 after U1 (documents the artifacts U1 produces);
orchestrator wiring (ruling 3) after U3+U5.
## Session U1: deploy artifacts + CI
Owns `deploy/systemd/nxdns.service`, `deploy/systemd/sysusers.conf`,
`deploy/docker/{Dockerfile,compose.yaml,.dockerignore}`, `.gitea/workflows/ci.yml`
(one added job). Rulings 4, 5, 6. Verify: `systemd-analyze verify` (or report
unavailable), local `docker build` + container smoke if the docker daemon is
reachable (report honestly either way), `zig build test` untouched-green.
## Session U2: operator.md + README.md (after U1)
Owns `docs/operator.md`, `README.md`. Rulings 7 (operator + README). Reads U1's
artifacts and the runtime/CLI facts from the code (verify against src/cli.zig,
src/app.zig, src/config/bootstrap.zig — not from memory).
## Session U3: config-reference.md
Owns `docs/config-reference.md`. Ruling 7. Source of truth: src/config/model.zig +
validate.zig + import/export/bootstrap. Every field, no sampling.
## Session U4: architecture.md
Owns `docs/architecture.md`. Ruling 7. Reads module headers; no deep dives needed
beyond what the doc claims.
## Session U5: api.md
Owns `docs/api.md`. Rulings 2, 7. Source of truth: src/web/routes.zig (the served
table: method, path, auth, limiter) + openapi.yaml summaries + auth.zig/sse.zig for
the auth and SSE prose. Every route, no sampling.
## As built
**U1** delivered per rulings 4-6 with accepted deviations: `StateDirectoryMode=0700`
(systemd defaults 0755; the binary cannot tighten a pre-existing directory) and
`User=nxdns`/`Group=nxdns` added to the unit; the Dockerfile's builder stage also maps
buildx TARGETARCH → cross-target dir and pre-chowns `/var/lib/nxdns` to 65532 (a named
volume seeded from a root-owned image dir would be unwritable on first boot); compose
gained a `build:` block; `deploy/docker/.dockerignore` is documentation-grade under
BuildKit (only a root `.dockerignore` or `Dockerfile.dockerignore` is honored — the
file's header says so). Verified: `systemd-analyze verify` clean modulo the off-host
ExecStart path (an ExecStart=/bin/true copy verifies exit 0); full local docker build +
smoke passed (binds 53 as uid 65532, /api/health ok, SIGTERM exit 0). The CI docker
job probes both 127.0.0.1 and the container IP to survive either runner topology.
Compose expects the operator-created seed at `deploy/docker/etc-nxdns/config.zon`
(minimal: a `default` group + one enabled upstream), else exit 2.
**U2** delivered docs/operator.md (397 lines; systemd + docker + Pi 5 recipes, seeding
semantics, auth, TLS, backup/restore, data-dir table, full CLI reference, exit codes,
troubleshooting incl. the systemd-resolved DNSStubListener recipe) and README.md
(72 lines, no license section — no LICENSE exists). All facts source-verified.
**U3** delivered docs/config-reference.md (12 scalar sections, 9 collections, DB-vs-file
truth model, auth section, minimal + annotated examples) and surfaced five code
discrepancies during writing (see fix wave below).
**U4** delivered docs/architecture.md (module map from the //! headers, purity rule
with the honest exceptions, life-of-one-query pipeline verified against handler.zig,
storage split, web stack, CertStore design, failure-visibility doctrine). Reported one
stale comment (logging.zig:17 cited a moved cli.zig line) — orchestrator fixed the
comment to cite start.zig:724 via std.process.Init.
**U5** delivered docs/api.md: all 56 operations (matches router.routes.len), auth /
rate-limit / SSE prose, error envelope, openapi.yaml pointer. No code-vs-yaml
discrepancies found.
**Orchestrator wiring (ruling 3)**: docs/docs.zig (embeds api.md, config-reference.md,
operator.md), `docs_files` anonymous import on the test module in build.zig,
src/docs_drift_test.zig with three containment guards (routes → api.md; toSettings
keys → config-reference.md; the six subcommand names → operator.md), tests.zig import.
**Fix wave (orchestrator-triaged; ruling 8's no-runtime-code rule lifted for exactly
these)** — U3's five discrepancies, triaged with stdlib evidence:
1. `runtime.io_backend` DELETED end to end (model, settings handler + view, openapi,
web types/SettingsPage/settingsDiff + tests, docs). Nothing consumed it — main uses
init.io (stdlib Threaded, start.zig:724), and 0.16's std.Io.Evented has stubbed
networking (Uring.zig netConnectIp → error.NetworkDown), so PLAN decision E's
"io_uring via flag" is not deliverable at this tag. Re-add when std ships working
evented net. Old DB rows warn-and-ignore via fromSettings' unknown-key path.
2. `upstream.connect_timeout_ms` DELETED. No call site; the pool races the whole
attempt against total_timeout (app.zig sets it from totalTimeout); Threaded panics
on IpAddress.ConnectOptions.timeout != .none; std.http.Client has no knob. The
validate cross-check is now total >= read only.
3. `cache.size` KEPT: 0 is clean documented disabled behavior (put short-circuits;
in-file test "a cache of zero entries stores nothing"). Doc row corrected.
4. `dns.bind_ipv6` TIGHTENED: checkBind generalized to a BindFamily enum; dns.bind_ipv6
requires an IPv6 literal (an IPv4 wildcard there made the v4 bind AddressInUse get
swallowed with a false "dual-stack" log — silent IPv6 loss). web/TLS binds stay .any.
5. doh/dot `readTimeout(.{})` sites KEPT: they are test fixtures; the real idle budget
is the ruled 10s Options default. The doc claim was wrong and was removed.
Settings key counts after deletion: toSettings emits 43 (incl. web.password_hash);
the API-visible restart-required set is 42 (was 44).
## Review (Codex, as built)
Three rounds on one thread; round 3 returned "No findings."
Round 1 (5 important): stale-DB bind_ipv6 rows bypassed the new validate check at boot
→ app.zig parseBind now enforces the IP family on both dns binds (BadBindAddress,
exit 2, remedy in the message; the v4 side had the symmetric hole); app.zig's tests
were not collected by tests.zig at all — the import line was added and the new test
runs. operator.md's TLS recipe assumed the nxdns host user → Docker path now chowns
65532:65532 numerically host-side (read-only bind mount). PLAN.md still promised the
io_uring flag and connect_timeout_ms → synced (Io bullet records the drop with stdlib
evidence; decision E row; example config). Drift guards were maskable → api.md guard
anchors the full "| METHOD | `pattern` |" row per operation; operator.md guard anchors
the "### `name" reference headings.
Round 2 (1 important, 1 minor): seed-permission guidance covered only web.password →
now web.password or web.password_hash (a restored export); PLAN.md's "stubs all
networking" overstated 0.16's Uring — now names the stubbed operations precisely.
Final gates: plain 1171/1284 passed, 113 skipped (integration-gated), 0 failed;
integration 1280/1284, 4 skipped (live-network by design), 0 failed; cross ReleaseSafe
with the SPA dist 18/18; web suite 121/121 with format/lint/typecheck clean.
## Module layout (new)
deploy/systemd/{nxdns.service,sysusers.conf}, deploy/docker/{Dockerfile,compose.yaml,
.dockerignore}, docs/{operator,architecture,config-reference,api}.md, README.md,
src/docs_drift_test.zig (orchestrator).
## File ownership
U1 deploy/* + ci.yml; U2 docs/operator.md + README.md; U3 docs/config-reference.md;
U4 docs/architecture.md; U5 docs/api.md; orchestrator src/docs_drift_test.zig,
build.zig (docs_files module), src/tests.zig.
## Acceptance (milestone complete)
- [ ] All files in the module layout exist with the ruling-7 content contracts met.
- [ ] Docs drift tests pass: route coverage in api.md, settings-key coverage in
config-reference.md, subcommand coverage in operator.md.
- [ ] CI docker job builds the image and the container smoke passes (in CI; locally
if the daemon is available).
- [ ] `systemd-analyze verify deploy/systemd/nxdns.service` clean, or its
unavailability recorded in the U1 report.
- [ ] Both suites 0 failed; cross ReleaseSafe with the SPA dist green.
- [ ] No runtime-behavior changes (git diff over src/ shows only docs_drift_test.zig
+ wiring).
## Anti-requirements
- No vendored API-doc renderer (redoc/scalar/swagger-ui), no YAML parser.
- No registry publish step; no k3s manifests (the infra repo owns deployment there).
- No SIGHUP/reload feature, no env-var config, no new CLI flags — document what
exists; report gaps instead of filling them.
- No LICENSE invention; no badges or marketing prose in README.
+59 -4
View File
@@ -458,8 +458,8 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
.reload_fn = reloadManager, .reload_fn = reloadManager,
}; };
const v6_bind = parseBind(r, cfg.dns.bind_ipv6, cfg.dns.port, "dns.bind_ipv6") catch |err| return err; const v6_bind = parseBind(r, cfg.dns.bind_ipv6, cfg.dns.port, "dns.bind_ipv6", .ip6) catch |err| return err;
const v4_bind = parseBind(r, cfg.dns.bind_ipv4, cfg.dns.port, "dns.bind_ipv4") catch |err| return err; const v4_bind = parseBind(r, cfg.dns.bind_ipv4, cfg.dns.port, "dns.bind_ipv4", .ip4) catch |err| return err;
// IPv6 first, and the order is load-bearing — see `Listeners`. // IPv6 first, and the order is load-bearing — see `Listeners`.
var udp6: ?udp_server.UdpServer = udp_server.UdpServer.bind(gpa, io, v6_bind, &h, .{}) catch |err| bound: { var udp6: ?udp_server.UdpServer = udp_server.UdpServer.bind(gpa, io, v6_bind, &h, .{}) catch |err| bound: {
@@ -826,11 +826,66 @@ const Listeners = struct {
tcp4: ?net.IpAddress, tcp4: ?net.IpAddress,
}; };
fn parseBind(r: cli.Runner, text: []const u8, port: u16, field: []const u8) !net.IpAddress { const BindFamily = enum { ip4, ip6 };
return net.IpAddress.parse(text, port) catch {
/// `config/validate.checkBind` enforces the same family rule on import, check
/// and settings PUT — but not on a config.db written before the rule existed,
/// and `serve` loads that DB without re-validating. Boot is the last seam: a
/// cross-family literal here would bind the wrong family's socket and make the
/// real one fail with AddressInUse, silently losing a family.
fn parseBind(
r: cli.Runner,
text: []const u8,
port: u16,
field: []const u8,
family: BindFamily,
) !net.IpAddress {
const addr = net.IpAddress.parse(text, port) catch {
r.err.print("{s}: '{s}' is not an IP address\n", .{ field, text }) catch {}; r.err.print("{s}: '{s}' is not an IP address\n", .{ field, text }) catch {};
return error.BadBindAddress; return error.BadBindAddress;
}; };
const matches = switch (addr) {
.ip4 => family == .ip4,
.ip6 => family == .ip6,
};
if (!matches) {
const digit: u8 = if (family == .ip4) '4' else '6';
r.err.print(
"{s}: '{s}' is not an IPv{c} address; re-import the configuration or correct it with a settings PUT\n",
.{ field, text, digit },
) catch {};
return error.BadBindAddress;
}
return addr;
}
test "parseBind refuses a bind address of the wrong family" {
var out_buf: [8]u8 = undefined;
var err_buf: [256]u8 = undefined;
var out: Writer = .fixed(&out_buf);
var err_writer: Writer = .fixed(&err_buf);
const r: cli.Runner = .{
.io = std.testing.io,
.gpa = std.testing.allocator,
.out = &out,
.err = &err_writer,
};
_ = try parseBind(r, "0.0.0.0", 53, "dns.bind_ipv4", .ip4);
_ = try parseBind(r, "::", 53, "dns.bind_ipv6", .ip6);
try std.testing.expectError(
error.BadBindAddress,
parseBind(r, "0.0.0.0", 53, "dns.bind_ipv6", .ip6),
);
try std.testing.expectError(
error.BadBindAddress,
parseBind(r, "::", 53, "dns.bind_ipv4", .ip4),
);
const printed = err_writer.buffered();
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "is not an IPv6 address"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "is not an IPv4 address"));
} }
fn reportBind(r: cli.Runner, which: []const u8, addr: net.IpAddress, err: anyerror) anyerror { fn reportBind(r: cli.Runner, which: []const u8, addr: net.IpAddress, err: anyerror) anyerror {
+2 -37
View File
@@ -18,7 +18,6 @@ const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
pub const Config = struct { pub const Config = struct {
runtime: Runtime = .{},
upstream: Upstream = .{}, upstream: Upstream = .{},
dns: Dns = .{}, dns: Dns = .{},
blocking: Blocking = .{}, blocking: Blocking = .{},
@@ -42,28 +41,7 @@ pub const Config = struct {
forward_zones: []const ForwardZone = &.{}, forward_zones: []const ForwardZone = &.{},
}; };
pub const IoBackend = enum {
threaded,
evented,
pub fn toDb(self: IoBackend) []const u8 {
return switch (self) {
.threaded => "threaded",
.evented => "evented",
};
}
pub fn fromDb(text: []const u8) ?IoBackend {
if (std.mem.eql(u8, text, "threaded")) return .threaded;
if (std.mem.eql(u8, text, "evented")) return .evented;
return null;
}
};
pub const Runtime = struct { io_backend: IoBackend = .threaded };
pub const Upstream = struct { pub const Upstream = struct {
connect_timeout_ms: u32 = 2000,
read_timeout_ms: u32 = 3000, read_timeout_ms: u32 = 3000,
total_timeout_ms: u32 = 5000, total_timeout_ms: u32 = 5000,
}; };
@@ -318,10 +296,6 @@ comptime {
assertFits(u32, 1024 * 1024, u64); // MiB conversions assertFits(u32, 1024 * 1024, u64); // MiB conversions
} }
pub fn connectTimeout(u: Upstream) std.Io.Duration {
return .{ .nanoseconds = @as(i96, u.connect_timeout_ms) * std.time.ns_per_ms };
}
pub fn readTimeout(u: Upstream) std.Io.Duration { pub fn readTimeout(u: Upstream) std.Io.Duration {
return .{ .nanoseconds = @as(i96, u.read_timeout_ms) * std.time.ns_per_ms }; return .{ .nanoseconds = @as(i96, u.read_timeout_ms) * std.time.ns_per_ms };
} }
@@ -506,8 +480,6 @@ const expected_keys = [_][]const u8{
"logging.output", "logging.output",
"logging.query_log_buffer_max", "logging.query_log_buffer_max",
"logging.retention_days", "logging.retention_days",
"runtime.io_backend",
"upstream.connect_timeout_ms",
"upstream.read_timeout_ms", "upstream.read_timeout_ms",
"upstream.total_timeout_ms", "upstream.total_timeout_ms",
"web.api_localhost_exempt", "web.api_localhost_exempt",
@@ -558,8 +530,7 @@ test "toSettings never emits web.password" {
test "toSettings and fromSettings round-trip a non-default config" { test "toSettings and fromSettings round-trip a non-default config" {
const gpa = testing.allocator; const gpa = testing.allocator;
const original: Config = .{ const original: Config = .{
.runtime = .{ .io_backend = .evented }, .upstream = .{ .read_timeout_ms = 222, .total_timeout_ms = 333 },
.upstream = .{ .connect_timeout_ms = 111, .read_timeout_ms = 222, .total_timeout_ms = 333 },
.dns = .{ .dns = .{
.bind_ipv4 = "127.0.0.1", .bind_ipv4 = "127.0.0.1",
.bind_ipv6 = "::1", .bind_ipv6 = "::1",
@@ -697,7 +668,6 @@ fn expectEnumRoundTrip(comptime E: type) !void {
} }
test "every toDb and fromDb enum pair round-trips over all tags" { test "every toDb and fromDb enum pair round-trips over all tags" {
try expectEnumRoundTrip(IoBackend);
try expectEnumRoundTrip(BlockResponse); try expectEnumRoundTrip(BlockResponse);
try expectEnumRoundTrip(EcsMode); try expectEnumRoundTrip(EcsMode);
try expectEnumRoundTrip(LogLevel); try expectEnumRoundTrip(LogLevel);
@@ -715,10 +685,6 @@ test "RecordType stores the uppercase DDL spelling" {
} }
test "unit conversions" { test "unit conversions" {
try testing.expectEqual(
@as(i96, 2000) * std.time.ns_per_ms,
connectTimeout(.{}).nanoseconds,
);
try testing.expectEqual( try testing.expectEqual(
@as(i96, 3000) * std.time.ns_per_ms, @as(i96, 3000) * std.time.ns_per_ms,
readTimeout(.{}).nanoseconds, readTimeout(.{}).nanoseconds,
@@ -737,13 +703,12 @@ test "unit conversions" {
test "unit conversions at the field maximum do not overflow" { test "unit conversions at the field maximum do not overflow" {
const max_upstream: Upstream = .{ const max_upstream: Upstream = .{
.connect_timeout_ms = std.math.maxInt(u32),
.read_timeout_ms = std.math.maxInt(u32), .read_timeout_ms = std.math.maxInt(u32),
.total_timeout_ms = std.math.maxInt(u32), .total_timeout_ms = std.math.maxInt(u32),
}; };
try testing.expectEqual( try testing.expectEqual(
@as(i96, std.math.maxInt(u32)) * std.time.ns_per_ms, @as(i96, std.math.maxInt(u32)) * std.time.ns_per_ms,
connectTimeout(max_upstream).nanoseconds, readTimeout(max_upstream).nanoseconds,
); );
try testing.expectEqual( try testing.expectEqual(
@as(i64, std.math.maxInt(u16)) * 3600, @as(i64, std.math.maxInt(u16)) * 3600,
+31 -14
View File
@@ -199,21 +199,20 @@ const max_rate_window_seconds = 3_600;
fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void { fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
const up = cfg.upstream; const up = cfg.upstream;
try checkTimeout(diags, up.connect_timeout_ms, "upstream.connect_timeout_ms");
try checkTimeout(diags, up.read_timeout_ms, "upstream.read_timeout_ms"); try checkTimeout(diags, up.read_timeout_ms, "upstream.read_timeout_ms");
try checkTimeout(diags, up.total_timeout_ms, "upstream.total_timeout_ms"); try checkTimeout(diags, up.total_timeout_ms, "upstream.total_timeout_ms");
if (up.total_timeout_ms < up.connect_timeout_ms or up.total_timeout_ms < up.read_timeout_ms) { if (up.total_timeout_ms < up.read_timeout_ms) {
try diags.add( try diags.add(
error.BadTimeout, error.BadTimeout,
"upstream.total_timeout_ms", "upstream.total_timeout_ms",
.{}, .{},
"total budget {d}ms is below connect {d}ms or read {d}ms", "total budget {d}ms is below read {d}ms",
.{ up.total_timeout_ms, up.connect_timeout_ms, up.read_timeout_ms }, .{ up.total_timeout_ms, up.read_timeout_ms },
); );
} }
try checkBind(diags, cfg.dns.bind_ipv4, "dns.bind_ipv4", true); try checkBind(diags, cfg.dns.bind_ipv4, "dns.bind_ipv4", .ip4);
try checkBind(diags, cfg.dns.bind_ipv6, "dns.bind_ipv6", false); try checkBind(diags, cfg.dns.bind_ipv6, "dns.bind_ipv6", .ip6);
try checkPort(diags, cfg.dns.port, "dns.port"); try checkPort(diags, cfg.dns.port, "dns.port");
if (cfg.dns.rate_limit < 1) { if (cfg.dns.rate_limit < 1) {
try diags.add(error.BadRateLimit, "dns.rate_limit", .{}, "must be at least 1", .{}); try diags.add(error.BadRateLimit, "dns.rate_limit", .{}, "must be at least 1", .{});
@@ -248,7 +247,7 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
); );
} }
try checkBind(diags, cfg.web.bind, "web.bind", false); try checkBind(diags, cfg.web.bind, "web.bind", .any);
try checkPort(diags, cfg.web.port, "web.port"); try checkPort(diags, cfg.web.port, "web.port");
if (cfg.web.password.len != 0 and cfg.web.password_hash.len != 0) { if (cfg.web.password.len != 0 and cfg.web.password_hash.len != 0) {
try diags.add( try diags.add(
@@ -339,18 +338,30 @@ fn checkTimeout(diags: *Diagnostics, value: u32, comptime path: []const u8) erro
} }
} }
const BindFamily = enum { ip4, ip6, any };
/// `dns.bind_ipv4` and `dns.bind_ipv6` each name one socket of the dual-stack
/// pair, so each must be a literal of its own family: an IPv4 wildcard in
/// `bind_ipv6` would bind IPv4 as the "v6" socket and make the real IPv4 bind
/// fail with AddressInUse — the IPv6 service silently disappears.
fn checkBind( fn checkBind(
diags: *Diagnostics, diags: *Diagnostics,
text: []const u8, text: []const u8,
comptime path: []const u8, comptime path: []const u8,
comptime require_ip4: bool, comptime family: BindFamily,
) error{OutOfMemory}!void { ) error{OutOfMemory}!void {
const addr = NetAddress.parse(text) catch { const addr = NetAddress.parse(text) catch {
try diags.add(error.BadBindAddress, path, .{}, "'{s}' is not an IP address", .{text}); try diags.add(error.BadBindAddress, path, .{}, "'{s}' is not an IP address", .{text});
return; return;
}; };
if (require_ip4 and std.meta.activeTag(addr) != NetAddress.ip4) { switch (family) {
try diags.add(error.BadBindAddress, path, .{}, "'{s}' is not an IPv4 address", .{text}); .ip4 => if (std.meta.activeTag(addr) != NetAddress.ip4) {
try diags.add(error.BadBindAddress, path, .{}, "'{s}' is not an IPv4 address", .{text});
},
.ip6 => if (std.meta.activeTag(addr) != NetAddress.ip6) {
try diags.add(error.BadBindAddress, path, .{}, "'{s}' is not an IPv6 address", .{text});
},
.any => {},
} }
} }
@@ -359,7 +370,7 @@ fn checkTlsEndpoint(
endpoint: model.TlsEndpoint, endpoint: model.TlsEndpoint,
comptime section: []const u8, comptime section: []const u8,
) error{OutOfMemory}!void { ) error{OutOfMemory}!void {
try checkBind(diags, endpoint.bind, section ++ ".bind", false); try checkBind(diags, endpoint.bind, section ++ ".bind", .any);
try checkPort(diags, endpoint.port, section ++ ".port"); try checkPort(diags, endpoint.port, section ++ ".port");
if (!endpoint.enabled) return; if (!endpoint.enabled) return;
// Readability of the files is `nxdns check`'s job, not the pure validator's. // Readability of the files is `nxdns check`'s job, not the pure validator's.
@@ -1162,11 +1173,11 @@ test "error.BadPort" {
test "error.BadTimeout" { test "error.BadTimeout" {
var cfg = baseConfig(); var cfg = baseConfig();
cfg.upstream.connect_timeout_ms = 10; cfg.upstream.read_timeout_ms = 10;
try expectProblem(cfg, error.BadTimeout, "upstream.connect_timeout_ms"); try expectProblem(cfg, error.BadTimeout, "upstream.read_timeout_ms");
var budget = baseConfig(); var budget = baseConfig();
budget.upstream = .{ .connect_timeout_ms = 4000, .read_timeout_ms = 4000, .total_timeout_ms = 1000 }; budget.upstream = .{ .read_timeout_ms = 4000, .total_timeout_ms = 1000 };
try expectProblem(budget, error.BadTimeout, "upstream.total_timeout_ms"); try expectProblem(budget, error.BadTimeout, "upstream.total_timeout_ms");
} }
@@ -1214,6 +1225,12 @@ test "error.BadBindAddress" {
try expectProblem(web, error.BadBindAddress, "web.bind"); try expectProblem(web, error.BadBindAddress, "web.bind");
} }
test "error.BadBindAddress on an IPv4 literal in dns.bind_ipv6" {
var cfg = baseConfig();
cfg.dns.bind_ipv6 = "0.0.0.0";
try expectProblem(cfg, error.BadBindAddress, "dns.bind_ipv6");
}
test "error.MissingCertPath" { test "error.MissingCertPath" {
var cfg = baseConfig(); var cfg = baseConfig();
cfg.doh_server = .{ .enabled = true, .cert_path = "" }; cfg.doh_server = .{ .enabled = true, .cert_path = "" };
+57
View File
@@ -0,0 +1,57 @@
//! Textual-containment guards that keep the hand-written docs honest
//! (milestone-11 ruling 3). They assert presence, not correctness — the same
//! contract as openapi.zig's route guard.
const std = @import("std");
const docs = @import("docs_files");
const routes = @import("web/routes.zig");
const model = @import("config/model.zig");
test "every served operation has its own table row in docs/api.md" {
const gpa = std.testing.allocator;
for (routes.table) |route| {
// Matches one full method + path cell pair ("| GET | `/api/groups` |"),
// so neither a same-path sibling method nor a longer-path prefix can
// satisfy the check for a missing operation.
const needle = try std.fmt.allocPrint(gpa, "| {s} | `{s}` |", .{
@tagName(route.method), route.pattern,
});
defer gpa.free(needle);
if (std.mem.indexOf(u8, docs.api_md, needle) == null) {
std.debug.print("operation row missing from docs/api.md: {s}\n", .{needle});
return error.OperationMissingFromApiDoc;
}
}
}
test "every settings key appears in docs/config-reference.md" {
const gpa = std.testing.allocator;
var pairs: std.ArrayList(model.SettingPair) = .empty;
defer {
model.freeSettings(gpa, pairs.items);
pairs.deinit(gpa);
}
try model.toSettings(.{}, gpa, &pairs);
for (pairs.items) |pair| {
if (std.mem.indexOf(u8, docs.config_reference_md, pair.key) == null) {
std.debug.print("settings key missing from docs/config-reference.md: {s}\n", .{pair.key});
return error.SettingsKeyMissingFromConfigDoc;
}
}
}
test "every cli subcommand has its own reference heading in docs/operator.md" {
const gpa = std.testing.allocator;
const subcommands = [_][]const u8{ "run", "check", "export", "import", "version", "help" };
for (subcommands) |name| {
// Anchors on the reference-section heading ("### `import FILE`" starts
// with "### `import"), so prose mentions elsewhere cannot mask a
// removed command section.
const needle = try std.fmt.allocPrint(gpa, "### `{s}", .{name});
defer gpa.free(needle);
if (std.mem.indexOf(u8, docs.operator_md, needle) == null) {
std.debug.print("subcommand heading missing from docs/operator.md: {s}\n", .{needle});
return error.SubcommandMissingFromOperatorDoc;
}
}
}
+3 -2
View File
@@ -14,8 +14,9 @@
//! need no `Io` because they read `std.Options.debug_io` (debug.zig:283), they //! need no `Io` because they read `std.Options.debug_io` (debug.zig:283), they
//! are documented as recursive (debug.zig:263-270), and `Io/Threaded.zig` //! are documented as recursive (debug.zig:263-270), and `Io/Threaded.zig`
//! implements that recursion per OS thread (Threaded.zig:13787-13796). nxdns //! implements that recursion per OS thread (Threaded.zig:13787-13796). nxdns
//! runs a `std.Io.Threaded` instance (cli.zig:793), so one task is one thread //! runs on the `std.Io.Threaded` instance the stdlib start code constructs
//! and the recursion holds. Taking it across the file writes as well keeps the //! (start.zig:724, handed to `main` via `std.process.Init`), so one task is
//! one thread and the recursion holds. Taking it across the file writes as well keeps the
//! file path and the stderr fallback path from interleaving with each other, //! file path and the stderr fallback path from interleaving with each other,
//! with `std.Progress`, or with a panic dump. //! with `std.Progress`, or with a panic dump.
//! //!
+2
View File
@@ -2,6 +2,7 @@ const std = @import("std");
comptime { comptime {
_ = @import("main.zig"); _ = @import("main.zig");
_ = @import("app.zig");
_ = @import("version.zig"); _ = @import("version.zig");
_ = @import("dns/types.zig"); _ = @import("dns/types.zig");
_ = @import("dns/header.zig"); _ = @import("dns/header.zig");
@@ -112,6 +113,7 @@ comptime {
_ = @import("server/dot_server.zig"); _ = @import("server/dot_server.zig");
_ = @import("server/doh_server.zig"); _ = @import("server/doh_server.zig");
_ = @import("web/handlers/certs.zig"); _ = @import("web/handlers/certs.zig");
_ = @import("docs_drift_test.zig");
} }
extern fn sqlite3_libversion() [*:0]const u8; extern fn sqlite3_libversion() [*:0]const u8;
-5
View File
@@ -189,7 +189,6 @@ fn newPassword(patch: Patch) ?[]const u8 {
// the read shape // the read shape
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const RuntimeView = struct { io_backend: []const u8 };
const BlockingView = struct { response: []const u8, ttl: u32 }; const BlockingView = struct { response: []const u8, ttl: u32 };
const EdnsView = struct { ecs_mode: []const u8 }; const EdnsView = struct { ecs_mode: []const u8 };
@@ -219,7 +218,6 @@ const WebView = struct {
}; };
pub const View = struct { pub const View = struct {
runtime: RuntimeView,
upstream: model.Upstream, upstream: model.Upstream,
dns: model.Dns, dns: model.Dns,
blocking: BlockingView, blocking: BlockingView,
@@ -235,7 +233,6 @@ pub const View = struct {
pub fn view(cfg: model.Config) View { pub fn view(cfg: model.Config) View {
return .{ return .{
.runtime = .{ .io_backend = cfg.runtime.io_backend.toDb() },
.upstream = cfg.upstream, .upstream = cfg.upstream,
.dns = cfg.dns, .dns = cfg.dns,
.blocking = .{ .response = cfg.blocking.response.toDb(), .ttl = cfg.blocking.ttl }, .blocking = .{ .response = cfg.blocking.response.toDb(), .ttl = cfg.blocking.ttl },
@@ -469,13 +466,11 @@ test "the read shape spells every enum the way the database does" {
.logging = .{ .level = .err, .output = .file }, .logging = .{ .level = .err, .output = .file },
.blocking = .{ .response = .nxdomain }, .blocking = .{ .response = .nxdomain },
.edns = .{ .ecs_mode = .forward }, .edns = .{ .ecs_mode = .forward },
.runtime = .{ .io_backend = .evented },
}); });
try testing.expectEqualStrings("error", rendered.logging.level); try testing.expectEqualStrings("error", rendered.logging.level);
try testing.expectEqualStrings("file", rendered.logging.output); try testing.expectEqualStrings("file", rendered.logging.output);
try testing.expectEqualStrings("nxdomain", rendered.blocking.response); try testing.expectEqualStrings("nxdomain", rendered.blocking.response);
try testing.expectEqualStrings("forward", rendered.edns.ecs_mode); try testing.expectEqualStrings("forward", rendered.edns.ecs_mode);
try testing.expectEqualStrings("evented", rendered.runtime.io_backend);
try testing.expect(!rendered.web.auth_enabled); try testing.expect(!rendered.web.auth_enabled);
const with_password = view(.{ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$a$b" } }); const with_password = view(.{ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$a$b" } });
+2 -15
View File
@@ -2088,20 +2088,12 @@ components:
Settings: Settings:
type: object type: object
required: [runtime, upstream, dns, blocking, cache, web, doh_server, dot_server, edns, logging, disk, blocklist_update] required: [upstream, dns, blocking, cache, web, doh_server, dot_server, edns, logging, disk, blocklist_update]
properties: properties:
runtime:
type: object
required: [io_backend]
properties:
io_backend:
type: string
enum: [threaded, evented]
upstream: upstream:
type: object type: object
required: [connect_timeout_ms, read_timeout_ms, total_timeout_ms] required: [read_timeout_ms, total_timeout_ms]
properties: properties:
connect_timeout_ms: { type: integer }
read_timeout_ms: { type: integer } read_timeout_ms: { type: integer }
total_timeout_ms: { type: integer } total_timeout_ms: { type: integer }
dns: dns:
@@ -2213,14 +2205,9 @@ components:
the write-only `web.password`. `web.password_hash` is rejected as the write-only `web.password`. `web.password_hash` is rejected as
an unknown field. an unknown field.
properties: properties:
runtime:
type: object
properties:
io_backend: { type: string }
upstream: upstream:
type: object type: object
properties: properties:
connect_timeout_ms: { type: integer }
read_timeout_ms: { type: integer } read_timeout_ms: { type: integer }
total_timeout_ms: { type: integer } total_timeout_ms: { type: integer }
dns: dns:
+1 -2
View File
@@ -525,8 +525,7 @@ const TlsEndpointView = struct {
/// response never carries `web.password` or `web.password_hash` (ruling 16). /// response never carries `web.password` or `web.password_hash` (ruling 16).
const SettingsView = struct { const SettingsView = struct {
settings: struct { settings: struct {
runtime: struct { io_backend: []const u8 }, upstream: struct { read_timeout_ms: u32, total_timeout_ms: u32 },
upstream: struct { connect_timeout_ms: u32, read_timeout_ms: u32, total_timeout_ms: u32 },
dns: struct { dns: struct {
bind_ipv4: []const u8, bind_ipv4: []const u8,
bind_ipv6: []const u8, bind_ipv6: []const u8,
@@ -10,8 +10,7 @@ import type { Settings, SettingsPatch } from "@/lib/types";
function baseSettings(): Settings { function baseSettings(): Settings {
return { return {
runtime: { io_backend: "threaded" }, upstream: { read_timeout_ms: 3000, total_timeout_ms: 5000 },
upstream: { connect_timeout_ms: 2000, read_timeout_ms: 3000, total_timeout_ms: 5000 },
dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 }, dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
blocking: { response: "zero", ttl: 300 }, blocking: { response: "zero", ttl: 300 },
cache: { size: 10000, negative_ttl_max: 300 }, cache: { size: 10000, negative_ttl_max: 300 },
@@ -33,12 +33,10 @@ const TLS_FIELDS: readonly FieldDef[] = [
]; ];
const SECTIONS: readonly SectionDef[] = [ const SECTIONS: readonly SectionDef[] = [
{ section: "runtime", title: "Runtime", fields: [{ key: "io_backend", kind: ["threaded", "evented"] }] },
{ {
section: "upstream", section: "upstream",
title: "Upstream", title: "Upstream",
fields: [ fields: [
{ key: "connect_timeout_ms", kind: "number" },
{ key: "read_timeout_ms", kind: "number" }, { key: "read_timeout_ms", kind: "number" },
{ key: "total_timeout_ms", kind: "number" }, { key: "total_timeout_ms", kind: "number" },
], ],
+1 -2
View File
@@ -3,8 +3,7 @@ import type { Settings } from "@/lib/types";
function baseSettings(): Settings { function baseSettings(): Settings {
return { return {
runtime: { io_backend: "threaded" }, upstream: { read_timeout_ms: 3000, total_timeout_ms: 5000 },
upstream: { connect_timeout_ms: 2000, read_timeout_ms: 3000, total_timeout_ms: 5000 },
dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 }, dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
blocking: { response: "zero", ttl: 300 }, blocking: { response: "zero", ttl: 300 },
cache: { size: 10000, negative_ttl_max: 300 }, cache: { size: 10000, negative_ttl_max: 300 },
-1
View File
@@ -1,7 +1,6 @@
import type { Settings, SettingsPatch } from "@/lib/types"; import type { Settings, SettingsPatch } from "@/lib/types";
const SECTIONS = [ const SECTIONS = [
"runtime",
"upstream", "upstream",
"dns", "dns",
"blocking", "blocking",
-5
View File
@@ -313,11 +313,7 @@ export interface TlsListenerSettings {
} }
export interface Settings { export interface Settings {
runtime: {
io_backend: "threaded" | "evented";
};
upstream: { upstream: {
connect_timeout_ms: number;
read_timeout_ms: number; read_timeout_ms: number;
total_timeout_ms: number; total_timeout_ms: number;
}; };
@@ -388,7 +384,6 @@ export interface TlsListenerPatch {
/** Partial update; `web.password` is write-only, `web.auth_enabled` is never sent. */ /** Partial update; `web.password` is write-only, `web.auth_enabled` is never sent. */
export interface SettingsPatch { export interface SettingsPatch {
runtime?: Partial<Settings["runtime"]>;
upstream?: Partial<Settings["upstream"]>; upstream?: Partial<Settings["upstream"]>;
dns?: Partial<Settings["dns"]>; dns?: Partial<Settings["dns"]>;
blocking?: Partial<Settings["blocking"]>; blocking?: Partial<Settings["blocking"]>;