milestone 11: systemd and docker packaging, operator and architecture docs, config and api reference, docs drift guards
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ Serves a household LAN (≈2–20 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) |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
u nxdns - "nxdns DNS sinkhole"
|
||||||
+169
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 | 100–120000 | read deadline on conditional-forward-zone exchanges (`src/server/handler.zig` via `app.zig`) |
|
||||||
|
| `upstream.total_timeout_ms` | u32 | 5000 | ms | 100–120000, 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 | 1–65535 | 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 | 1–3600 | 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 | 1–65535 | 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 | 1–65535 | 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 | 1–65535 | 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 | 1–604800 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" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
@@ -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");
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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 = "" };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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"]>;
|
||||||
|
|||||||
Reference in New Issue
Block a user