Files
nxdns/specs/milestone-11.md
T

17 KiB

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.