274 lines
17 KiB
Markdown
274 lines
17 KiB
Markdown
# 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.
|