Compare commits
12
Commits
v0.0.6
...
aa4a8f1573
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa4a8f1573 | ||
|
|
a8e0fe4617 | ||
|
|
348e955b8f | ||
|
|
ffc3ca61ba | ||
|
|
5c89acf337 | ||
|
|
3c2d0d41f0 | ||
|
|
31a6f0c5e5 | ||
|
|
51d8281abb | ||
|
|
266dde7396 | ||
|
|
32cd9b8e3e | ||
|
|
e14a29c5de | ||
|
|
da4441b24a |
@@ -123,57 +123,12 @@ jobs:
|
||||
|
||||
# The licence inventory has to cover every package whose bytes ship, and
|
||||
# the lockfile does not answer that question: it lists what could be
|
||||
# reached, not what rollup kept. Four packages of the non-dev closure are
|
||||
# recorded as tree-shaken away, and if application code starts importing
|
||||
# one of them, no lockfile, no version and no dependency set changes —
|
||||
# only the bundle does. So the bundle is what this reads.
|
||||
#
|
||||
# A second build with sourcemaps, because the shipped build has none: the
|
||||
# `sources` list of each chunk names the packages whose modules went into
|
||||
# it. The output goes to its own directory so the artifact npm run build
|
||||
# produced is the one that gets embedded, untouched.
|
||||
# reached, not what rollup kept. The bundle is what this reads. The logic
|
||||
# lives in web/scripts/, unit-tested by `npm test`, so it runs on a laptop
|
||||
# exactly as it runs here (milestone-14 deviation 24).
|
||||
- name: Assert the packages bundled into web/dist are the recorded ones
|
||||
working-directory: web
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The binary npm ci installed, never `npx`: npx silently downloads a
|
||||
# package it cannot find locally, so a wrong working directory would
|
||||
# turn a licence check into an unpinned fetch from the network.
|
||||
./node_modules/.bin/vite build --sourcemap --outDir dist-sourcemap --emptyOutDir >/dev/null
|
||||
|
||||
maps=$(find dist-sourcemap -name '*.map' -type f | LC_ALL=C sort)
|
||||
if [ -z "$maps" ]; then
|
||||
echo "the sourcemap build produced no .map files; this check cannot run blind"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
bundled=$(jq -r '.sources[]' $maps \
|
||||
| grep 'node_modules/' \
|
||||
| sed 's|.*node_modules/||' \
|
||||
| awk -F/ '{ if ($1 ~ /^@/) print $1"/"$2; else print $1 }' \
|
||||
| LC_ALL=C sort -u)
|
||||
|
||||
recorded=$(awk '
|
||||
/^\[npm packages bundled into web\/dist\]$/ { grab = 1; next }
|
||||
grab && /^\[/ { exit }
|
||||
grab && NF { print }
|
||||
' ../licenses/dependency-identity.txt | LC_ALL=C sort -u)
|
||||
|
||||
if [ -z "$recorded" ]; then
|
||||
echo "licenses/dependency-identity.txt has no '[npm packages bundled into web/dist]' section"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! diff -u <(printf '%s\n' "$recorded") <(printf '%s\n' "$bundled"); then
|
||||
echo
|
||||
echo "the set of npm packages in web/dist has changed (-recorded +current)."
|
||||
echo "Work out what the change means for licenses/inventory.zon first, then record"
|
||||
echo "the new list in that section of licenses/dependency-identity.txt."
|
||||
exit 1
|
||||
fi
|
||||
echo "web/dist bundles exactly the recorded packages:"
|
||||
printf '%s\n' "$bundled"
|
||||
run: npm run assert-bundled
|
||||
|
||||
package:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
+175
-1140
File diff suppressed because it is too large
Load Diff
+41
-1
@@ -10,7 +10,47 @@ subject rarely does.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0.1] - 2026-08-07
|
||||
### Added
|
||||
|
||||
- **Declarative configuration for IaC.** `nxdns run --config=<file>` makes the
|
||||
file the sole source of configuration: every boot converges the database to
|
||||
it in one transaction, preserving blocklist downloads, compiled lists and
|
||||
client history, so an unchanged file costs zero downloads and zero writes.
|
||||
Bare `nxdns run` keeps the database (and the web UI) in charge, exactly as
|
||||
before. In file mode the web UI is read-only for configuration and says so;
|
||||
runtime actions (pause, blocklist refresh, certificate reload) stay live.
|
||||
`GET /api/settings` reports which authority governs the process.
|
||||
- `nxdns import` now refuses a file whose application would delete
|
||||
configuration rows, names the tables and counts, and applies it only with
|
||||
the new `--allow-delete` flag. Additive and edit-in-place imports need no
|
||||
flag.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Breaking: `nxdns run --config <file>` changed meaning.** It used to seed
|
||||
the database once and then ignore the file; it now makes the file the
|
||||
authority on every boot, which deletes any configuration the file does not
|
||||
declare — including edits made through the web UI since the seed. Before
|
||||
upgrading a unit that carries `--config`: either drop the flag to keep the
|
||||
database in charge, or adopt file mode with the sequence in the upgrade
|
||||
guide. Order matters there: export the file with the NEW binary (stopped).
|
||||
- **Breaking: 0.0.1 exports are refused by this version.** A 0.0.1
|
||||
`nxdns export` writes both `.password = ""` and the stored
|
||||
`.password_hash`, and this version refuses a file that carries both. This
|
||||
bites any old export — an adoption file or a configuration backup fed to
|
||||
`nxdns import` alike. Fix an existing export by deleting its
|
||||
`.password = ""` line (keep the `.password_hash` line). Take fresh backups
|
||||
with the new binary.
|
||||
- **Breaking: the offline password-change recipe changed.** Setting
|
||||
`.password = "new"` together with `.password_hash = ""` is now refused
|
||||
(empty `password_hash` is an explicit "disable authentication", and the two
|
||||
fields cannot both be present). To change the password in the file: set
|
||||
`.password` and delete the `.password_hash` line entirely.
|
||||
- `nxdns import --force` is renamed `--allow-delete`.
|
||||
- A fresh install no longer seeds from `/etc/nxdns/config.zon` by presence.
|
||||
Use `nxdns import` once, or run in file mode with `--config`.
|
||||
|
||||
## [0.0.1] - 2026-08-09
|
||||
|
||||
First release. Everything below is new.
|
||||
|
||||
|
||||
+19
-9
@@ -41,10 +41,9 @@ Do not create `/var/lib/nxdns` or `/var/log/nxdns` by hand. The unit's
|
||||
`StateDirectory` and `LogsDirectory` settings make systemd create them on first
|
||||
start, `/var/lib/nxdns` at mode 0700 owned by `nxdns`.
|
||||
|
||||
## 2. Write the seed configuration
|
||||
## 2. Write the configuration
|
||||
|
||||
nxdns starts from an empty database only if a configuration file tells it what
|
||||
to forward to. Write `/etc/nxdns/config.zon`:
|
||||
nxdns will not start with nothing to forward to. Write `/etc/nxdns/config.zon`:
|
||||
|
||||
```zon
|
||||
.{
|
||||
@@ -76,16 +75,27 @@ A good file ends with `OK: no problems found`. Exit 2 means `check` found
|
||||
something to fix and printed every problem it found. The upstream probe sends a
|
||||
real query, so this needs working DNS on the host.
|
||||
|
||||
The file seeds the database once. From the second start onwards it is ignored
|
||||
and the database is the configuration. The seed's `web.password` is hashed at
|
||||
import time and the plaintext is never stored, so once you have logged in you
|
||||
can delete the file:
|
||||
Load it into the database:
|
||||
|
||||
```sh
|
||||
nxdns import /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
The packaged unit runs `nxdns run` with no `--config`, so from here the database
|
||||
is the configuration and nothing reads the file again. `web.password` is hashed
|
||||
and the plaintext is never stored, so once you have logged in you can delete the
|
||||
file:
|
||||
|
||||
```sh
|
||||
rm /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
A kept seed is not a backup. `nxdns export` is.
|
||||
A kept file is not a backup. `nxdns export` is.
|
||||
|
||||
To keep the file as the configuration instead — converged at every start, with
|
||||
the UI refusing configuration edits — do not delete it, and add a drop-in that
|
||||
appends `--config=/etc/nxdns/config.zon` to `ExecStart`. See
|
||||
`docs/how-to/install-with-systemd.md`.
|
||||
|
||||
## 3. Start it
|
||||
|
||||
@@ -107,7 +117,7 @@ dig @<server-ip> example.com A +short
|
||||
```
|
||||
|
||||
The admin interface is on port 8080 by default; log in with the password from
|
||||
the seed file. `http://<server-ip>:8080/api/health` reports upstream
|
||||
the configuration file. `http://<server-ip>:8080/api/health` reports upstream
|
||||
availability and disk state without a login.
|
||||
|
||||
## More
|
||||
|
||||
@@ -9,6 +9,8 @@ you what asked for what.
|
||||
|
||||
- Blocklist filtering: subscribe to hosts/domain lists, plus your own allow
|
||||
and block rules with wildcard support (`*.example.com`)
|
||||
- Two configuration modes: a database the web UI edits, or a ZON file you keep
|
||||
in git and converge onto at every start
|
||||
- Per-client policy groups: different filtering for the kids' tablet and
|
||||
your workstation
|
||||
- Local DNS records and conditional forwarding for internal zones
|
||||
@@ -39,7 +41,7 @@ says what that signature does and does not prove.
|
||||
|
||||
## Quickstart (docker compose)
|
||||
|
||||
Seed a minimal configuration and start the published image. This is what the
|
||||
Write a minimal configuration and start the published image. This is what the
|
||||
first release will make possible; it does not work today, because there is no
|
||||
image in the registry to pull:
|
||||
|
||||
@@ -60,11 +62,20 @@ The compose file defaults to `:latest`; pin a version for anything you intend
|
||||
to keep running. To run it before a release exists, build the image yourself and
|
||||
name it — `NXDNS_IMAGE=nxdns docker compose up -d` — as
|
||||
[docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md)
|
||||
describes. 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
|
||||
describes. DNS is on port 53, the web UI on <http://localhost:8080>.
|
||||
|
||||
The compose file runs `nxdns run --config=/etc/nxdns/config.zon`, which makes
|
||||
that file the configuration: every start reconciles the database onto it, and
|
||||
the UI refuses configuration edits. Edit the file and restart to change
|
||||
anything. Drop the `command:` line to run bare `nxdns run` instead, where the
|
||||
database is the configuration and changes go through the UI, the API, or
|
||||
`nxdns export` / `nxdns import` — the packaged systemd unit does that. Which
|
||||
mode is live is printed at every start (`authority: database` /
|
||||
`authority: file (<path>)`); see
|
||||
[docs/explanation/configuration-model.md](docs/explanation/configuration-model.md).
|
||||
|
||||
Full install instructions, including the systemd path and the Pi 5 recipe, are
|
||||
in
|
||||
[docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) and
|
||||
[docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md).
|
||||
|
||||
|
||||
@@ -218,6 +218,27 @@ pub fn build(b: *std.Build) void {
|
||||
b.step("test-aarch64", "Run the test suite for aarch64-linux-musl (use -fqemu)")
|
||||
.dependOn(&aarch64_run.step);
|
||||
|
||||
// The release publication tool (milestone-14 deviation 24). It is a host
|
||||
// tool like `dist_stage` and `verify_dist`, and it is installed rather than
|
||||
// run from the build graph: the workflow invokes it once per phase with the
|
||||
// secrets in its environment, and a Run step would have to carry them.
|
||||
const release_tool = hostTool(b, "release");
|
||||
b.step("release-tool", "Install the release publication tool into zig-out/bin")
|
||||
.dependOn(&b.addInstallArtifact(release_tool, .{}).step);
|
||||
|
||||
// Its pure decisions — semver ordering, VALIDSIG field selection, changelog
|
||||
// extraction, the releases-payload shape guard — are the reason it exists,
|
||||
// so they run in the same `zig build test` as everything else.
|
||||
const release_tests = b.addTest(.{
|
||||
.name = "release-tool",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("tools/release.zig"),
|
||||
.target = b.graph.host,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
test_step.dependOn(&b.addRunArtifact(release_tests).step);
|
||||
|
||||
addDist(b, options, web_assets, .{
|
||||
.version = version_option,
|
||||
.version_string = version_string,
|
||||
|
||||
@@ -6,10 +6,17 @@ services:
|
||||
# NXDNS_IMAGE=nxdns.
|
||||
image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-latest}}
|
||||
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.
|
||||
# `run --config` makes the file the sole source of configuration: nxdns
|
||||
# reconciles the database onto ./etc-nxdns/config.zon at every start, and
|
||||
# rejects configuration writes from the admin UI. Edit the file and restart
|
||||
# the container to change anything. The file needs a `default` group and at
|
||||
# least one enabled upstream, or the container exits with code 2. A
|
||||
# recreated nxdns-data volume rebuilds itself from the file on next start.
|
||||
#
|
||||
# Drop this line to let the database be the truth instead, and load a first
|
||||
# configuration once with:
|
||||
# docker compose run --rm nxdns import /etc/nxdns/config.zon
|
||||
command: ["run", "--config=/etc/nxdns/config.zon"]
|
||||
volumes:
|
||||
- ./etc-nxdns:/etc/nxdns:ro
|
||||
- nxdns-data:/var/lib/nxdns
|
||||
|
||||
@@ -16,6 +16,11 @@ StateDirectoryMode=0700
|
||||
LogsDirectory=nxdns
|
||||
ConfigurationDirectory=nxdns
|
||||
|
||||
# ConfigurationDirectory creates /etc/nxdns owned by the service user. nxdns
|
||||
# never writes there in either authority mode, and in file mode that directory
|
||||
# holds the source of truth, so deny the write outright rather than rely on it.
|
||||
ReadOnlyPaths=/etc/nxdns
|
||||
|
||||
# Port 53 (and 443/853 when the DoH/DoT listeners are enabled).
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
@@ -44,6 +49,9 @@ SystemCallArchitectures=native
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
# Exit 2 is a configuration fault and 64 is a usage error. Neither clears on a
|
||||
# retry, so a restart loop only buries the diagnostics already in the journal.
|
||||
RestartPreventExitStatus=2 64
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ are inspectable.
|
||||
- [explanation/architecture.md](explanation/architecture.md) — the module map
|
||||
and the design it comes from.
|
||||
- [explanation/configuration-model.md](explanation/configuration-model.md) — why
|
||||
the file seeds the database once and the database is the truth afterwards.
|
||||
there are two authority modes, how each one is selected, and what each is for.
|
||||
- [explanation/performance-and-testing.md](explanation/performance-and-testing.md)
|
||||
— why the targets exist, why CI does not gate on them, and what the hermetic
|
||||
tests do and do not prove.
|
||||
|
||||
@@ -35,7 +35,7 @@ Directories:
|
||||
| `src/upstream/` | Upstream resolution: shared vocabulary and the `Client` interface (`transport.zig`), DoH client (RFC 8484), DoT client (RFC 7858), per-endpoint health and backoff (`health.zig`), and `pool.zig` — priority-ordered 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 and SSE fanout), `shutdown.zig` (SIGINT/SIGTERM into 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 to and from `config.db`, byte-stable round trip), `bootstrap.zig` (first-start seeding — a policy wrapper over import). |
|
||||
| `src/config/` | The one configuration model (`model.zig`), the pure validator (`validate.zig`), `import.zig`/`export.zig` (ZON to and from `config.db`, byte-stable round trip), `loader.zig` (read/parse/validate a named file, with the shared fault mapping), `reconcile.zig` (converge the database onto a parsed config by row identity). |
|
||||
| `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`). |
|
||||
|
||||
@@ -47,7 +47,7 @@ main.zig ── cli.zig ── app.zig (composition root)
|
||||
│ injects std.Io + collaborators
|
||||
┌──────────────────────┴───────────────────────┐
|
||||
│ server/ web/ upstream/ storage/ │ I/O edge
|
||||
│ platform/ config/{import,export,bootstrap} │
|
||||
│ platform/ config/{loader,reconcile,import} │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ dns/ filter/* local/* cache/ │ pure core:
|
||||
│ config/{model,validate} │ bytes in, bytes out
|
||||
@@ -162,14 +162,20 @@ working resolver over a correct-looking failure.
|
||||
Two databases with opposite contracts, in one data directory (see
|
||||
[reference/files-and-directories.md](../reference/files-and-directories.md)).
|
||||
|
||||
**`config.db` is the truth.** Its schema is versioned: `migrations.zig` holds
|
||||
**`config.db` is what the server reads.** Its schema is versioned: `migrations.zig` holds
|
||||
an ordered list of steps, step 1 being the verbatim DDL from
|
||||
`config_schema.zig`, each applied inside one transaction. `nxdns import`
|
||||
replaces the whole content atomically under `BEGIN IMMEDIATE`, so a failed
|
||||
import changes nothing; `nxdns export` renders it back as canonical ZON,
|
||||
byte-identical across round trips. A config file seeds this database exactly
|
||||
once at first start. Why it works that way is
|
||||
[configuration-model.md](configuration-model.md).
|
||||
byte-identical across round trips.
|
||||
|
||||
Which of the file and the database is *authoritative* is chosen by the
|
||||
invocation, not by state: bare `nxdns run` serves the database, and
|
||||
`nxdns run --config FILE` makes the file authoritative and reconciles the
|
||||
database onto it at every start. `reconcile.zig` is that convergence, matching
|
||||
rows by identity and writing only differences, so runtime state — blocklist
|
||||
checksums, compiled snapshots, client history — survives. Why it works that way
|
||||
is [configuration-model.md](configuration-model.md).
|
||||
|
||||
**`querylog.db` is expendable.** It is never migrated. Its schema carries a
|
||||
fingerprint derived from the DDL text, and at open, a missing, corrupt,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# The configuration model
|
||||
|
||||
nxdns is configured two ways — a ZON file and a web UI — and only one of them
|
||||
can be the truth. This page explains which, and why that choice is the one
|
||||
that leaves the fewest ways to lose an operator's work.
|
||||
can be the truth at a time. This page explains how that choice is made, what
|
||||
each mode is for, and why the design leaves the fewest ways to lose an
|
||||
operator's work.
|
||||
|
||||
For the fields themselves see
|
||||
[reference/configuration.md](../reference/configuration.md); for the commands
|
||||
@@ -10,146 +11,169 @@ and their exit codes see [reference/cli.md](../reference/cli.md).
|
||||
|
||||
## The rule
|
||||
|
||||
The database is the truth. The file is a seed.
|
||||
**Authority is the invocation.**
|
||||
|
||||
`config.db` in the data directory holds the running configuration. The ZON
|
||||
file (default `/etc/nxdns/config.zon`, overridable with `--config`) is read on
|
||||
`nxdns run` only while the database is still empty: if the file exists and the
|
||||
database holds no configuration, it is imported. Once the import has put rows
|
||||
in, the file is not opened again.
|
||||
```
|
||||
nxdns run the database is the truth
|
||||
nxdns run --config /etc/nxdns/config.zon the file is the truth
|
||||
```
|
||||
|
||||
The condition is the state of the database, not a one-shot flag. A `run` whose
|
||||
seed fails — unreadable file, parse error, failed validation, a constraint
|
||||
violation inside the import transaction — leaves the database empty, so the
|
||||
next `run` reads the file again. That is what makes fixing a typo and starting
|
||||
again work.
|
||||
That is the entire selection mechanism. There is no mode setting, no default
|
||||
file path, and nothing recorded in the database about which mode last wrote it.
|
||||
A `config.zon` that exists but that no invocation names changes nothing at all.
|
||||
|
||||
`src/config/bootstrap.zig` is that policy and nothing else — a wrapper over
|
||||
the same import path `nxdns import` uses. Three outcomes:
|
||||
Two properties fall out of that, and both were chosen on purpose.
|
||||
|
||||
- no file: the database is used as it is;
|
||||
- file present, database empty: seed it;
|
||||
- file present, database already configured: skip, without reading the file.
|
||||
**An operator can read `ExecStart` and know which authority is live.** The
|
||||
alternative — probe a well-known path, and behave differently depending on
|
||||
whether a file happens to be there — is ambient magic. It is also the exact
|
||||
class of rule that produced years of documentation lies in this project: the
|
||||
old design read the file only while the database was empty, which meant the same
|
||||
command did two different things depending on state nobody could see from the
|
||||
command line, and every page that described it eventually described it wrongly.
|
||||
|
||||
A file that exists but is unreadable, unparseable or invalid fails the start,
|
||||
with every problem printed. nxdns never falls back to silent defaults over a
|
||||
file an operator wrote — a resolver that boots "successfully" with a
|
||||
configuration nobody chose is the worst outcome available, because it looks
|
||||
like it worked.
|
||||
**A path already expresses a two-state choice, so a mode flag beside it would
|
||||
be redundant and worse.** An earlier draft had `--config-source=db|file`. A mode
|
||||
flag next to a path flag manufactures combinations that cannot mean anything —
|
||||
a path with no mode, a mode with no path — and each one then needs a pairing
|
||||
rule and a usage error to defend it. Presence-of-path has no invalid
|
||||
combinations, so there is nothing to defend.
|
||||
|
||||
## Why the database wins
|
||||
## Database mode
|
||||
|
||||
The alternative designs all lose data.
|
||||
`nxdns run`. `config.db` holds the configuration; the UI, the API and `nxdns
|
||||
import` write to it; nothing reads a file. This is the appliance: someone sets
|
||||
the box up once, and afterwards the household member who wants to unblock one
|
||||
domain clicks a button.
|
||||
|
||||
If the file were the truth, the admin UI could not write. Every change would
|
||||
be an SSH session and a restart, which defeats the reason the UI exists: the
|
||||
household member who wants to unblock one domain is not going to edit ZON.
|
||||
A fresh install in this mode starts from an empty database, which fails
|
||||
validation on its own terms — there is nowhere to forward a query to — and says
|
||||
what to do about it:
|
||||
|
||||
If both were the truth, they would disagree. The UI writes a rule; the file
|
||||
still says otherwise; the next restart either silently reverts the rule or
|
||||
silently ignores the file. Both are silent, and both destroy work someone
|
||||
intended to keep. There is no merge rule that fixes this, because the system
|
||||
cannot know which of two conflicting statements is the newer intention.
|
||||
```
|
||||
nxdns run failed: NoUsableUpstreams
|
||||
run `nxdns check` to see the configuration in full
|
||||
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
|
||||
```
|
||||
|
||||
So the file's authority ends the moment the database has content. Editing
|
||||
`config.zon` after first boot does nothing — no partial effect, no warning
|
||||
that some fields took and others did not. That is a blunt rule, and it is the
|
||||
point: the failure mode is "my edit did nothing", which is visible the first
|
||||
time you look at the UI, rather than "my edit was applied and then quietly
|
||||
undone next Tuesday".
|
||||
## File mode
|
||||
|
||||
The emptiness check is a real query over the content tables, not a flag: the
|
||||
database counts as configured when a content table holds rows, or when the
|
||||
default group has been altered. What it measures is intent, not activity, and
|
||||
the client table is where those two come apart. Clients are auto-materialised
|
||||
when they first send a query — the DNS path writes a row per device it sees —
|
||||
and such a row records what the network did, not what an operator decided. It
|
||||
carries `hand_edited = 0`, the emptiness check counts only the `hand_edited = 1`
|
||||
rows, and `export` omits them. So answering queries never turns an unconfigured
|
||||
database into a configured one; naming a device does.
|
||||
`nxdns run --config FILE`. The file is the sole declarative source, and the
|
||||
database becomes the runtime substrate: every start reads the file, validates
|
||||
it, converges the database onto it, and serves from there. Configuration writes
|
||||
through the API are refused with a 403.
|
||||
|
||||
Counting traffic here would have been a quiet trap: a server that resolved one
|
||||
name would have declared itself configured and ignored a seed file placed
|
||||
afterwards, and the operator would have had no line of output saying why.
|
||||
This is the mode for a file kept in git and pushed by Ansible. What it buys is
|
||||
that the deployed file is what is running — not "was imported once", not
|
||||
"was imported unless someone clicked something since".
|
||||
|
||||
The same distinction survives an import. `import` replaces the content in one
|
||||
transaction, which empties `clients` along with every other table, so every
|
||||
client row is saved before the wipe. The materialised ones are put back after it
|
||||
unchanged, and restoring a backup does not make the server forget the devices it
|
||||
has met.
|
||||
Three properties make it usable rather than merely correct.
|
||||
|
||||
An address the imported file names belongs to the file — the operator's
|
||||
statement wins over the discovered row — with one carve-out. First-seen and
|
||||
last-seen are not configuration: they record when a device was heard from, the
|
||||
configuration model has no field for either, and an import is not a query. So
|
||||
they follow the address rather than the row. If the database already knew that
|
||||
address, its two timestamps are carried onto the new row; only an address the
|
||||
database has never seen takes the import's clock. Without that, re-importing a
|
||||
backup would stamp every device the operator had bothered to name as though it
|
||||
had just arrived — and those are exactly the devices whose history is worth
|
||||
something.
|
||||
**It fails closed.** A file that is missing, unreadable, unparseable, oversized
|
||||
or invalid stops the start. nxdns never falls back to the database, because a
|
||||
fallback turns a deploy typo into a configuration that is silently months old
|
||||
and looks fine. That failure is exit 2, so `nxdns check --config FILE` is a real
|
||||
pre-restart gate: validate the pushed file in the handler, and a typo is a
|
||||
failed deploy at noon rather than a dead resolver at the next power cut.
|
||||
|
||||
**It converges rather than replaces.** Reconciling matches rows by identity and
|
||||
writes only what differs. A source whose URL has not changed keeps its row id,
|
||||
its checksum, its counters and its compiled blocklist files — so a restart in
|
||||
file mode downloads nothing, which is the difference between a design that is
|
||||
tolerable to restart and one that costs three minutes and 100 MB every time.
|
||||
|
||||
**An unchanged file writes nothing at all.** Not "writes the same bytes" —
|
||||
performs zero write statements, and reports it:
|
||||
|
||||
```
|
||||
reconciled '/etc/nxdns/config.zon': no changes
|
||||
```
|
||||
|
||||
That matters beyond elegance. A box whose SD card is full of query log can still
|
||||
restart in file mode, because a no-op reconcile needs no write-ahead-log
|
||||
headroom.
|
||||
|
||||
**Converged at every boot is not a lock between boots.** Nothing stops `nxdns
|
||||
import` or a `runtime action` route from moving the database while the server
|
||||
runs. The contract is that the next start puts it back, and says what it
|
||||
corrected.
|
||||
|
||||
## What the file cannot take away
|
||||
|
||||
The file is authoritative over configuration. It is not authoritative over
|
||||
things it has no vocabulary for, and reconciling has to preserve those or the
|
||||
mode is unusable.
|
||||
|
||||
- **Blocklist download state.** Checksums, fetch timestamps and domain counts
|
||||
belong to the network, not the operator. They survive on every matched row.
|
||||
- **Client history.** Devices nxdns saw on the wire are kept whole. Naming one
|
||||
in the file promotes that row in place — it keeps its first-seen and
|
||||
last-seen and its row id, and counts as an update rather than a delete and an
|
||||
insert.
|
||||
- **Devices whose group is un-declared.** Remove a group from the file and the
|
||||
observed clients assigned to it move to `default`. The operator un-declared
|
||||
the group, not the devices.
|
||||
- **The password, when the file does not mention it.** See
|
||||
[the password](#the-password).
|
||||
|
||||
The one thing identity cannot survive is a change to identity itself. Edit a
|
||||
source's URL and the engine sees one row gone and one row arrived: new id, fresh
|
||||
download, and the old compiled files swept. That is consistent — artifacts are
|
||||
keyed by row id — and it is why the CLI asks for `--allow-delete` when a diff
|
||||
deletes anything.
|
||||
|
||||
## Why the database is the substrate in both modes
|
||||
|
||||
Even in file mode the database is where the server reads its effective
|
||||
configuration from. That is not a leftover; it is what lets one read path serve
|
||||
both modes, and it is what makes the runtime state above have somewhere to live.
|
||||
|
||||
If the file were read directly on every query path there would be no place to
|
||||
keep a checksum, and no way for the UI to show anything. If both file and
|
||||
database were authoritative there would be a two-way merge, and a merge cannot
|
||||
work here: when the UI writes a rule and the file still says otherwise, nothing
|
||||
in the system knows which of two statements is the newer intention. Every
|
||||
resolution silently destroys work someone meant to keep.
|
||||
|
||||
So the modes are exclusive, and the failure mode of each is loud. In database
|
||||
mode, editing the file does nothing, which you find out the first time you look
|
||||
at the UI. In file mode, the UI refuses the edit to your face with a message
|
||||
naming the file to edit instead.
|
||||
|
||||
## The round trip
|
||||
|
||||
Losing the file as an editing surface would be a real loss — text is
|
||||
diffable, reviewable and easy to back up — so the file is kept as a
|
||||
*rendering* of the database rather than a rival to it. That is what
|
||||
`export`/`import` are for:
|
||||
The file stays useful as an editing surface in both modes — text is diffable,
|
||||
reviewable and easy to back up — because `export` renders the database into the
|
||||
same shape `import` and file mode read:
|
||||
|
||||
```
|
||||
nxdns export → canonical ZON → edit → nxdns import --force → config.db
|
||||
nxdns export → canonical ZON → edit → nxdns import → config.db
|
||||
```
|
||||
|
||||
`nxdns export` renders the database as canonical ZON: a fixed two-line
|
||||
header, every default emitted, deterministic ordering from the model's field
|
||||
order and the repositories' `ORDER BY` clauses, and no timestamps or hostnames
|
||||
anywhere. Runtime columns — first-seen, last-seen, per-source counters — are
|
||||
absent from the configuration model on purpose, so two exports taken from a
|
||||
live, busy server are identical. The round trip `export → import → export` is
|
||||
byte-identical, and a test asserts it.
|
||||
`nxdns export` writes canonical ZON: a fixed two-line header, every default
|
||||
emitted, deterministic ordering from the model's field order and the
|
||||
repositories' `ORDER BY` clauses, and no timestamps or hostnames anywhere.
|
||||
Runtime columns are absent from the configuration model on purpose, so two
|
||||
exports taken from a live, busy server are identical. `export → import →
|
||||
export` is byte-identical, and a test asserts it.
|
||||
|
||||
Byte-stability is not cosmetic. It is what makes an exported file usable in
|
||||
version control and what makes a diff of two exports mean something: any
|
||||
difference is a configuration change, never noise from when the export ran.
|
||||
|
||||
`nxdns import` replaces the whole database content in one transaction. Not a
|
||||
merge, not a patch: delete every content table in foreign-key-safe order, then
|
||||
insert what the file says — with the one exception described above. Every client
|
||||
row is lifted out first. The materialised ones are put back rather than
|
||||
recreated from a file that never held them, and the observed timestamps of an
|
||||
address the file *does* name are merged onto its new row. A client the file
|
||||
leaves out is gone, history included: nothing puts a `hand_edited = 1` row back.
|
||||
A failed import — bad syntax, failed
|
||||
validation, a constraint violation halfway through — leaves the database exactly
|
||||
as it was, because everything happens inside a single `BEGIN IMMEDIATE`.
|
||||
The output is also identical in both modes — nothing marks a file as coming
|
||||
from a file-mode box. That is deliberate, because it is what makes export the
|
||||
adoption tool: the file you check is the file you deploy, byte for byte. The
|
||||
label an operator wants is in the unit file, where they put it.
|
||||
|
||||
Without `--force`, import refuses a database that already holds configuration.
|
||||
That
|
||||
check runs *inside* the transaction, after the lock is taken, so it cannot be
|
||||
raced by a concurrent write. The effect is that a plain `import` can never
|
||||
clobber a configured server by accident, and clobbering it deliberately takes
|
||||
one visible extra word on the command line. See
|
||||
[how-to/back-up-and-restore.md](../how-to/back-up-and-restore.md).
|
||||
|
||||
## One model, three surfaces
|
||||
|
||||
`src/config/model.zig` defines exactly one `Config` type. The ZON parser
|
||||
produces it, the database reader produces it, the validator consumes it, the
|
||||
composition root consumes it, and the settings API derives its key list and
|
||||
its patch struct from its type information rather than mirroring the fields by
|
||||
hand. Adding a field in one place therefore cannot leave the other surfaces
|
||||
behind; the alternative — a file schema, a DB schema and an API schema kept in
|
||||
step by discipline — is the standard way configuration systems rot.
|
||||
|
||||
The model has two shapes of field, and the difference is structural rather
|
||||
than stylistic. Struct-typed fields are scalar sections (`dns`, `cache`,
|
||||
`web`, …) and live in a single key/value `settings` table as
|
||||
`section.field` text rows. Slice-typed fields are collections (groups,
|
||||
upstreams, clients, rules, local records, forward zones, …) and each gets its
|
||||
own table with foreign keys. `toSettings` and `fromSettings` are the two
|
||||
halves of the scalar bridge, generated by an `inline for` over the model, so
|
||||
the key list is a consequence of the type rather than a second list to
|
||||
maintain.
|
||||
Reconciling the same file twice produces a byte-identical database — ids,
|
||||
checksums, `created_at`, the password hash, the whole settings table — including
|
||||
when the file is written in a non-canonical but equivalent form, such as
|
||||
`FD00:0:0:0:0:0:0:1` for an address stored as `fd00::1`. Anything that churns
|
||||
under an unchanged file is a bug in the engine by definition. That single
|
||||
invariant is what forces most of the design above: matching on canonical forms,
|
||||
writing only on difference, treating duplicate rule tuples as a multiset, and
|
||||
verifying a password rather than re-hashing it.
|
||||
|
||||
## Unknown keys, and why the asymmetry is deliberate
|
||||
|
||||
@@ -157,52 +181,65 @@ An unknown key in the **database** is warned about and ignored. An unknown key
|
||||
in the **file** is a hard error.
|
||||
|
||||
They are different situations. A settings row the running binary does not
|
||||
recognise is almost always an older binary reading a database written by a
|
||||
newer one — a downgrade, or a rollback after a bad upgrade. Refusing to start
|
||||
there would mean a downgrade bricks the config database, and the operator
|
||||
would have to hand-edit SQLite to recover. Warning and ignoring means the
|
||||
downgrade works, the unknown setting sits inert, and the upgrade back picks it
|
||||
up again.
|
||||
recognise is almost always an older binary reading a database written by a newer
|
||||
one — a downgrade, or a rollback after a bad upgrade. Refusing to start there
|
||||
would mean a downgrade bricks the config database, and the operator would have
|
||||
to hand-edit SQLite to recover. Warning and ignoring means the downgrade works,
|
||||
the unknown setting sits inert, and the upgrade back picks it up again.
|
||||
|
||||
A key in a file, by contrast, is something a human just typed. The likeliest
|
||||
cause is a typo, and the second likeliest is a field that no longer exists.
|
||||
Silently ignoring it would mean the setting the operator believes they applied
|
||||
was never applied — exactly the silent-divergence failure the whole model is
|
||||
built to avoid. So the ZON parser rejects unknown fields with a line and
|
||||
column.
|
||||
built to avoid. So the ZON parser rejects unknown fields with a line and column.
|
||||
|
||||
The tolerance has a boundary worth stating plainly: it covers unknown *keys*,
|
||||
not unparseable *values*. A known key whose stored text does not decode into
|
||||
its type is an error, not a warning.
|
||||
not unparseable *values*. A known key whose stored text does not decode into its
|
||||
type is an error, not a warning.
|
||||
|
||||
## The password
|
||||
|
||||
`web.password` is a write-only input. It is never a stored value.
|
||||
`web.password` is a write-only input. It is never a stored value. A non-empty
|
||||
one is hashed with argon2id (PHC encoding, OWASP argon2id parameters) into
|
||||
`web.password_hash`, and the plaintext is cleared before anything is written.
|
||||
There is no settings row that can hold it: the model skips `web.password` in
|
||||
both directions of the settings bridge, so the plaintext has nowhere to go even
|
||||
by accident.
|
||||
|
||||
At import time, a non-empty `web.password` is hashed with argon2id (PHC
|
||||
encoding, OWASP argon2id parameters) into `web.password_hash`, and the
|
||||
plaintext field is cleared before anything is written. There is no settings
|
||||
row that can hold it: the model explicitly skips `web.password` in both
|
||||
directions of the settings bridge, so the plaintext has nowhere to go even by
|
||||
accident. `nxdns export` always writes `.password = ""` and carries the hash
|
||||
instead — which is also what makes the round trip stable, since an export that
|
||||
tried to reproduce a plaintext it never had could not be byte-identical.
|
||||
Both fields are optional, and this is the one deliberate carve-out from
|
||||
file-as-sole-truth: **a file that mentions neither leaves the stored hash
|
||||
alone.**
|
||||
|
||||
Setting both `password` and `password_hash` in one file is an error rather
|
||||
than a precedence rule. The two say different things about what the password
|
||||
is, and picking a winner would mean the operator's other statement was
|
||||
silently discarded. The file has to say one thing.
|
||||
The reason is a trap the design walked into once. An export carries the full PHC
|
||||
string, which is long and ugly, and an operator committing that file to git will
|
||||
sooner or later delete the line — meaning "keep the current password". If
|
||||
absence meant "no password", that edit would reconcile an empty hash over the
|
||||
stored one and open the admin UI to the entire LAN, silently, because
|
||||
authentication is on exactly when the hash is non-empty. Silence has to mean
|
||||
keep. Disabling authentication takes the explicit `password_hash = ""`.
|
||||
|
||||
The practical shape of a password change is therefore export, set `.password`
|
||||
to the new value, clear `.password_hash`, import with `--force`. The procedure
|
||||
is in
|
||||
[how-to/set-up-admin-authentication.md](../how-to/set-up-admin-authentication.md).
|
||||
The other end of the same problem is `password = ""`. Hashing the empty string
|
||||
produces a perfectly valid hash, so authentication would be *on* — while the
|
||||
login handler refuses every empty password, so it could never be satisfied.
|
||||
Auth on and unreachable is worse than either alternative, so that file is
|
||||
refused at validation with a diagnostic naming the remedy.
|
||||
|
||||
A plaintext password that has not changed is verified against the stored hash
|
||||
and kept rather than re-hashed. That is byte-stability, not a saving:
|
||||
verification recomputes the same argon2id function with the stored salt and
|
||||
costs exactly what hashing costs. Hashing unconditionally would generate a fresh
|
||||
salt on every start and break the invariant above.
|
||||
|
||||
Export's canonical form is therefore `password = null` beside the stored
|
||||
`password_hash`. Writing an empty *string* there instead would make every export
|
||||
carry a present-but-empty password next to a hash — tripping the both-set rule
|
||||
on re-import, so export's own output would fail export's own contract.
|
||||
|
||||
## What is not configuration
|
||||
|
||||
Storage paths are process arguments, not configuration fields: `--data-dir`,
|
||||
`--config`, `--web-dev`. They cannot live in the file, because the file is
|
||||
found by way of them — a path that told you where to find the thing that told
|
||||
you the path would be circular. They are also the settings a supervisor
|
||||
(systemd, Docker) owns rather than the operator's policy about DNS. See
|
||||
`--config`, `--web-dev`. They cannot live in the file, because the file is found
|
||||
by way of them — a path that told you where to find the thing that told you the
|
||||
path would be circular. They are also the settings a supervisor (systemd,
|
||||
Docker) owns rather than the operator's policy about DNS. See
|
||||
[reference/files-and-directories.md](../reference/files-and-directories.md).
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
# Back up and restore
|
||||
|
||||
The configuration database is the only state worth keeping. `nxdns export`
|
||||
writes it out as a ZON file and `nxdns import` writes one back. The query log is
|
||||
deliberately not part of a backup: it is expendable history, and if it is
|
||||
missing it gets recreated empty.
|
||||
The configuration is the only state worth keeping. `nxdns export` writes it out
|
||||
as a ZON file and `nxdns import` writes one back. The query log is deliberately
|
||||
not part of a backup: it is expendable history, and if it is missing it gets
|
||||
recreated empty.
|
||||
|
||||
**Which authority the service runs under decides what the backup *is*.** Check
|
||||
the start log:
|
||||
|
||||
- `authority: database` — `config.db` holds the configuration. Back it up with
|
||||
`nxdns export`, and restore with `nxdns import` or by replacing the database
|
||||
file.
|
||||
- `authority: file (<path>)` — that file holds the configuration, and it is
|
||||
already a text file you can keep in git. **The file is the backup.** Restoring
|
||||
means putting the file back and restarting; the database rebuilds itself from
|
||||
it. `config.db` is a cache of the file in this mode, not the thing to preserve.
|
||||
|
||||
The rest of this page covers database mode unless it says otherwise.
|
||||
|
||||
The commands below use the scratch lab from
|
||||
[enable DoH and DoT](enable-doh-and-dot.md), data directory
|
||||
@@ -40,13 +53,16 @@ grep password /tmp/nxdns-lab/backup.zon
|
||||
```
|
||||
|
||||
```
|
||||
.password = "",
|
||||
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$Gh+zg9xke6BqSVOiouRbqG50+Bs8ZGcXA6oKgs7lrKg$crTNMu5OI8yKBkp31r4+Y1OUQmLiAlH/qvsIxjBQRq4",
|
||||
.password = null,
|
||||
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$hOjjnTrTZ6kU8XrwQuJ7ZFC3B5LumaB4hRe7kBbzJ6Q$YsC2rCTDKv96eEwPhs+D6vbDliLogEZph8EkagKSuy8",
|
||||
```
|
||||
|
||||
Treat backups as secrets. `.password` is always exported as `""` — the
|
||||
Treat backups as secrets. `.password` is always exported as `null` — the
|
||||
plaintext is never stored anywhere — so the file re-imports without anyone
|
||||
knowing the password.
|
||||
knowing the password. `null` and `""` are different statements here: `null`
|
||||
means the file says nothing about the password, while an empty `password_hash`
|
||||
would disable authentication. See
|
||||
[Password and hash](../reference/configuration.md#password-and-hash).
|
||||
|
||||
Without `--out` the export goes to stdout, where the file mode is your
|
||||
redirect's problem:
|
||||
@@ -57,7 +73,7 @@ nxdns export --data-dir /tmp/nxdns-lab/data | head -10
|
||||
|
||||
```
|
||||
// nxdns configuration
|
||||
// generated by `nxdns export` — the database is the source of truth
|
||||
// generated by `nxdns export` from the running configuration
|
||||
.{
|
||||
.upstream = .{
|
||||
.attempt_timeout_ms = 2500,
|
||||
@@ -75,8 +91,8 @@ exports taken minutes apart differ.
|
||||
|
||||
## Restore onto a fresh data directory
|
||||
|
||||
This is the normal restore: new machine, new disk, empty data directory. No
|
||||
`--force`, because there is nothing to overwrite.
|
||||
This is the normal restore: new machine, new disk, empty data directory.
|
||||
Nothing to overwrite, so nothing to authorise.
|
||||
|
||||
```sh
|
||||
nxdns import /tmp/nxdns-lab/backup.zon --data-dir /tmp/nxdns-lab/data-restored
|
||||
@@ -92,64 +108,95 @@ before writing to it.
|
||||
|
||||
## Restore over an existing database
|
||||
|
||||
`import` refuses a database that already holds configuration, so a plain
|
||||
`import` can never clobber a configured server by accident:
|
||||
|
||||
```sh
|
||||
nxdns import /tmp/nxdns-lab/backup.zon --data-dir /tmp/nxdns-lab/data
|
||||
```
|
||||
|
||||
```
|
||||
import failed: DatabaseNotEmpty
|
||||
```
|
||||
|
||||
That exits 2. Say `--force` when replacing is what you mean:
|
||||
|
||||
```sh
|
||||
nxdns import /tmp/nxdns-lab/backup.zon --force --data-dir /tmp/nxdns-lab/data
|
||||
```
|
||||
|
||||
```
|
||||
imported /tmp/nxdns-lab/backup.zon
|
||||
```
|
||||
|
||||
Stop the server first. `import` replaces the whole configuration underneath a
|
||||
process that has already read it, and a running server will not notice.
|
||||
|
||||
What `--force` does to the client list is worth knowing before you restore an
|
||||
old backup. A client the backup does not name is removed, and its first-seen
|
||||
and last-seen go with it — restoring a month-old file drops the devices you
|
||||
named since. Devices the server discovered from traffic are kept, and a device
|
||||
the backup does name keeps the first-seen and last-seen the database already
|
||||
held, so a restore does not restamp your whole network as newly arrived.
|
||||
|
||||
On a real install that is the systemd unit:
|
||||
**Stop the server first.** `import` rewrites configuration underneath a process
|
||||
that read it at startup, and a running server picks up only part of it —
|
||||
filtering follows the new rows at the next reload, while upstreams, listeners
|
||||
and settings stay at their boot values until a restart.
|
||||
|
||||
```sh
|
||||
systemctl stop nxdns
|
||||
nxdns import /var/backups/nxdns-config.zon --force
|
||||
nxdns import /var/backups/nxdns-config.zon
|
||||
systemctl start nxdns
|
||||
```
|
||||
|
||||
**Not verified on this host.** Those three lines are the only commands on this
|
||||
page that were not run: this machine has no installed nxdns systemd unit
|
||||
(`systemctl status nxdns` answers `Unit nxdns.service could not be found.`) and
|
||||
`systemctl stop`/`start` need root. The lab equivalent below was run, and it
|
||||
exercises the same stop-import-start sequence. In the lab the server is a
|
||||
foreground `nxdns run`, so stopping it is Ctrl-C in its own terminal:
|
||||
`import` converges the database onto the file. Rows the file still names are
|
||||
matched and updated in place; rows it no longer names are deleted. That last
|
||||
part is what a restore of an old backup does to everything added since, so it
|
||||
takes a flag:
|
||||
|
||||
```sh
|
||||
nxdns export --data-dir /tmp/nxdns-lab/data --out /tmp/nxdns-lab/pre-restore.zon
|
||||
# Ctrl-C the `nxdns run` terminal, or `kill` its pid from another shell
|
||||
nxdns import /tmp/nxdns-lab/pre-restore.zon --force --data-dir /tmp/nxdns-lab/data
|
||||
nxdns run --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon
|
||||
nxdns import /tmp/nxdns-lab/old-backup.zon --data-dir /tmp/nxdns-lab/data
|
||||
```
|
||||
|
||||
```
|
||||
wrote /tmp/nxdns-lab/pre-restore.zon
|
||||
imported /tmp/nxdns-lab/pre-restore.zon
|
||||
FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it
|
||||
import failed: DestructiveImport
|
||||
```
|
||||
|
||||
That exits 2 and rolls the transaction back, so nothing is half-applied. The
|
||||
message names every table that would lose rows, which is usually enough to tell
|
||||
an intended restore from the wrong file. Say `--allow-delete` when deleting is
|
||||
what you mean:
|
||||
|
||||
```sh
|
||||
nxdns import /tmp/nxdns-lab/old-backup.zon --allow-delete --data-dir /tmp/nxdns-lab/data
|
||||
```
|
||||
|
||||
```
|
||||
imported /tmp/nxdns-lab/old-backup.zon
|
||||
```
|
||||
|
||||
A restore that only puts back what is already there needs no flag at all, and
|
||||
neither does one that only adds rows. The flag is about deletion specifically —
|
||||
including deletion in disguise: renaming a group, or correcting a typo in an
|
||||
upstream URL, changes the row's identity, so the engine sees one row gone and
|
||||
one arrived.
|
||||
|
||||
What survives a restore is worth knowing before you take an old backup out of
|
||||
the drawer. Blocklist download state is kept for every source whose URL the file
|
||||
still names — checksum, counters, compiled files, and the row id they are keyed
|
||||
by — so restoring does not cost a re-download. Devices the server discovered
|
||||
from traffic are kept whole, and one the backup names keeps the first-seen and
|
||||
last-seen the database already held, so a restore never restamps your network as
|
||||
newly arrived. What does go is a client the backup does not name and that was
|
||||
named by hand: that row is declarative, and it is deleted with the rest.
|
||||
|
||||
> The three `systemctl` lines are the only commands on this page that were not
|
||||
> run: this machine has no installed nxdns unit (`systemctl status nxdns`
|
||||
> answers `Unit nxdns.service could not be found.`) and `systemctl stop`/`start`
|
||||
> need root. The `nxdns import` between them is the same command the lab blocks
|
||||
> above run, which were executed here as written.
|
||||
|
||||
## Restoring the database file itself
|
||||
|
||||
Copying `config.db` back into place works too, and it is the fastest restore on
|
||||
a machine that still has one. Two rules, and the second one is where restores go
|
||||
wrong:
|
||||
|
||||
1. **Take the copy from a stopped instance**, or use `nxdns export` instead. A
|
||||
copy taken while nxdns is running catches the main file without the changes
|
||||
sitting in its write-ahead log.
|
||||
2. **Delete any stale `config.db-wal` and `config.db-shm` beside the file you
|
||||
restore.** SQLite silently discards a write-ahead log that does not match the
|
||||
database it sits next to. It does not warn, and it does not fail — it just
|
||||
answers from the main file, so a restore quietly loses its own tail.
|
||||
|
||||
```sh
|
||||
systemctl stop nxdns
|
||||
rm -f /var/lib/nxdns/config.db-wal /var/lib/nxdns/config.db-shm
|
||||
cp /var/backups/config.db /var/lib/nxdns/config.db
|
||||
chown nxdns:nxdns /var/lib/nxdns/config.db
|
||||
chmod 0600 /var/lib/nxdns/config.db
|
||||
systemctl start nxdns
|
||||
```
|
||||
|
||||
> Not verified on this host: these need root, an installed unit and
|
||||
> `/var/lib/nxdns`, none of which exist here.
|
||||
|
||||
None of this applies in file mode. There `config.db` is derived state — restore
|
||||
the configuration file and start the service, and the first reconcile rebuilds
|
||||
the database from it.
|
||||
|
||||
## Verify a backup
|
||||
|
||||
The round trip is byte-stable: exporting, importing and exporting again gives
|
||||
@@ -200,5 +247,6 @@ startup and before `export` and `import`; nothing walks them back, and `nxdns
|
||||
check` does not run them at all. Take an export before installing a new binary —
|
||||
see [upgrade](upgrade.md).
|
||||
|
||||
Every command on this page was executed on this host as written, except the
|
||||
`systemctl` block marked **Not verified on this host** above.
|
||||
Every `nxdns` command on this page was executed on this host as written. The
|
||||
`systemctl`, `cp`, `chown` and `chmod` lines were not: they need root and an
|
||||
installed unit, and both blocks holding them say so.
|
||||
|
||||
@@ -62,10 +62,12 @@ to:
|
||||
}
|
||||
```
|
||||
|
||||
Write that to `/tmp/nxdns-lab/etc/config.zon`. The configuration file seeds an
|
||||
empty database and is then ignored; to change these settings on a server that
|
||||
already has a database, edit them through the API or through
|
||||
`export`/`import` — see [the configuration model](../explanation/configuration-model.md).
|
||||
Write that to `/tmp/nxdns-lab/etc/config.zon`. The lab runs
|
||||
`nxdns run --config`, which makes that file the configuration: every start
|
||||
reconciles the database onto it, so editing the file and restarting is how these
|
||||
settings change here. A real install may instead run bare `nxdns run` and keep
|
||||
the configuration in the database — see
|
||||
[the configuration model](../explanation/configuration-model.md).
|
||||
|
||||
## 3. Check the files before starting
|
||||
|
||||
|
||||
@@ -10,16 +10,29 @@ supported and is the last section of this page.
|
||||
For what each configuration field means, see
|
||||
[the configuration reference](../reference/configuration.md).
|
||||
|
||||
> Verification: the seed-file failure modes, the run and the two checks in
|
||||
> step 3 were run on the machine that wrote this page, against an image built
|
||||
> from this checkout rather than pulled from the registry — no release is
|
||||
> Verification: the failure modes, the run and the two checks in step 3 were run
|
||||
> on the machine that wrote an earlier revision of this page, against an image
|
||||
> built from this checkout rather than pulled from the registry — no release is
|
||||
> published yet, so nothing on this page could be run against a pulled image,
|
||||
> and step 1 could not be run at all. One command was run in altered form:
|
||||
> host port 8080 was occupied here, so the run and the verification commands
|
||||
> in step 3 were executed with the host side of the port mappings moved to
|
||||
> 25353 and 28088 rather than the 53 and 8080 printed below. The container
|
||||
> side was unchanged. See the note in step 3. The `chown` to uid 65532 needs
|
||||
> root and was not run.
|
||||
> and step 1 could not be run at all. One command was run in altered form: host
|
||||
> port 8080 was occupied there, so the run and the verification commands in
|
||||
> step 3 were executed with the host side of the port mappings moved to 25353
|
||||
> and 28088 rather than the 53 and 8080 printed below. The container side was
|
||||
> unchanged. See the note in step 3. The `chown` to uid 65532 needs root and was
|
||||
> not run.
|
||||
>
|
||||
> **Not re-run for the file-mode revision.** The compose file now ships
|
||||
> `command: ["run", "--config=/etc/nxdns/config.zon"]`, and no container was
|
||||
> started against that command on this host: staging a release image needs
|
||||
> `zig build dist`, which refuses to run while `web/dist` is stale, and the web
|
||||
> bundle was being rebuilt by other work in the same tree at the time. What was
|
||||
> checked instead is `docker compose -f deploy/docker/compose.yaml config`,
|
||||
> which resolves the file without contacting a registry and prints the `command`
|
||||
> and the `:ro` bind mount as written, and the same
|
||||
> `run --config=<file>` invocation driven directly against the locally built
|
||||
> binary: it printed the reconcile summary, `authority: file (<path>)`, and
|
||||
> `no changes` on the second start. The log lines quoted in step 3 come from
|
||||
> that run, with the paths and ports the container uses.
|
||||
|
||||
## 1. Pull and verify the image
|
||||
|
||||
@@ -65,7 +78,7 @@ does and does not prove.
|
||||
> `docker buildx imagetools inspect --format` shape was run here against
|
||||
> `alpine:3.22` on Docker Hub and printed that image's index digest.
|
||||
|
||||
## 2. Get the compose file and write the seed configuration
|
||||
## 2. Get the compose file and write the configuration
|
||||
|
||||
Every path on this page is relative to a checkout of the repository, because
|
||||
that is how it was verified. Running the published image needs no checkout,
|
||||
@@ -82,7 +95,7 @@ curl -fLO "$BASE/raw/tag/v$VERSION/deploy/docker/compose.yaml"
|
||||
> Gitea `1.27.0+dev` and returned the file with a 200.
|
||||
|
||||
Compose bind-mounts `deploy/docker/etc-nxdns` read-only at `/etc/nxdns`. Create
|
||||
it and put the seed file in it:
|
||||
it and put the configuration in it:
|
||||
|
||||
```sh
|
||||
mkdir -p deploy/docker/etc-nxdns
|
||||
@@ -100,15 +113,25 @@ upstream:
|
||||
}
|
||||
```
|
||||
|
||||
Without that file the container exits with code 2 on a fresh volume: an empty
|
||||
database has nothing to forward to. The log is
|
||||
`no configuration file at '/etc/nxdns/config.zon'; using the database as it is`
|
||||
followed by `nxdns run failed: NoUsableUpstreams`.
|
||||
**The compose file ships file mode**, with
|
||||
`command: ["run", "--config=/etc/nxdns/config.zon"]`. That file is the
|
||||
configuration: the container reconciles its database onto it at every start, and
|
||||
the admin interface answers 403 to configuration edits. To change anything, edit
|
||||
the file and restart the container. It also means a fresh or recreated
|
||||
`nxdns-data` volume rebuilds itself from the mounted file with no extra step.
|
||||
|
||||
The file is therefore required, and its absence is a hard failure rather than a
|
||||
start with defaults:
|
||||
|
||||
```
|
||||
FAIL /etc/nxdns/config.zon: no such file
|
||||
nxdns run failed: ManagedConfigUnreadable
|
||||
run `nxdns check` to see the configuration in full
|
||||
```
|
||||
|
||||
A file that is present but rejected is a different failure with the same exit
|
||||
code. No `default` group, no enabled upstream, a syntax error — `run` prints the
|
||||
diagnostic and exits 2 as well. Both were run here against a locally built
|
||||
image. A seed file whose only group was named `other`:
|
||||
diagnostic and exits 2 as well. A file whose only group was named `other`:
|
||||
|
||||
```
|
||||
FAIL groups: no group named 'default'; every unknown client is assigned to it
|
||||
@@ -116,18 +139,31 @@ nxdns run failed: MissingDefaultGroup
|
||||
run `nxdns check` to see the configuration in full
|
||||
```
|
||||
|
||||
and an empty `/etc/nxdns`:
|
||||
Under `restart: unless-stopped` any of these is a restart loop — Docker has no
|
||||
start limit and will retry forever. Read the lines above the failure, which name
|
||||
the fault. See [Troubleshoot nxdns](troubleshoot.md).
|
||||
|
||||
```
|
||||
info(config_bootstrap): no configuration file at '/etc/nxdns/config.zon'; using the database as it is
|
||||
nxdns run failed: NoUsableUpstreams
|
||||
run `nxdns check` to see the configuration in full
|
||||
### Database mode in Docker instead
|
||||
|
||||
Drop the `command:` line from `compose.yaml` and the container runs
|
||||
`nxdns run`, with the database as the configuration and the file read by nothing.
|
||||
On a fresh volume that database is empty and the container exits 2 with
|
||||
`NoUsableUpstreams`, so load it once before bringing the service up:
|
||||
|
||||
```sh
|
||||
docker compose -f deploy/docker/compose.yaml run --rm nxdns import /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
Under `restart: unless-stopped` either one is a restart loop, and the exit code
|
||||
alone no longer tells them apart: read the lines above the failure, which either
|
||||
name the diagnostic in the file or say there was no file at all. See
|
||||
[Troubleshoot nxdns](troubleshoot.md).
|
||||
The file is positional; add `--allow-delete` when re-running it against a
|
||||
populated volume and the diff deletes rows. Without this step, `restart:
|
||||
unless-stopped` plus exit 2 is a crash loop with no way out.
|
||||
|
||||
> Not run in a container on this host, for the reason in the verification note
|
||||
> at the top: no image could be staged here. The `nxdns import <file>` and
|
||||
> `nxdns import <file> --allow-delete` commands inside it were run directly
|
||||
> against the locally built binary — the first applied an additive file and
|
||||
> exited 0, the second was required after a plain `import` refused a
|
||||
> row-deleting file with `DestructiveImport` and exited 2.
|
||||
|
||||
The container runs as uid 65532, and the mount is read-only, so the container
|
||||
cannot repair permissions itself. Mode 0644 works and was used here. If the
|
||||
@@ -140,9 +176,12 @@ chmod 0600 deploy/docker/etc-nxdns/config.zon
|
||||
```
|
||||
|
||||
> Not verified on this host: `chown` to a uid you do not own needs root. What
|
||||
> was verified is the failure it prevents — a seed file at 0600 owned by
|
||||
> another uid makes the container log `nxdns run failed: AccessDenied` and
|
||||
> restart in a loop. See [Troubleshoot nxdns](troubleshoot.md).
|
||||
> was verified is the failure it prevents — a configuration file at 0600 owned
|
||||
> by another uid makes the container refuse to start and restart in a loop. In
|
||||
> file mode an unreadable file is a configuration fault:
|
||||
> `FAIL /etc/nxdns/config.zon: not readable` followed by
|
||||
> `nxdns run failed: ManagedConfigUnreadable`, exit 2. See
|
||||
> [Troubleshoot nxdns](troubleshoot.md).
|
||||
|
||||
## 3. Run it
|
||||
|
||||
@@ -169,14 +208,21 @@ bind mount — against the directory holding the file, not against your shell,
|
||||
and it takes the project name `docker` from that directory either way, which is
|
||||
why the container is `docker-nxdns-1`.
|
||||
|
||||
A healthy first start logs the seeding and the bound sockets:
|
||||
A healthy first start logs the reconcile, the authority and the bound sockets:
|
||||
|
||||
```
|
||||
info(config_bootstrap): seeded the database from '/etc/nxdns/config.zon'
|
||||
info(migrations): config.db migrated from schema version 0 to 2
|
||||
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +45 ~0 -0;
|
||||
settings keys changed: dns.bind_ipv4 dns.bind_ipv6 dns.port web.bind web.port …
|
||||
web authentication is now enabled
|
||||
info(nxdns): authority: file (/etc/nxdns/config.zon)
|
||||
info(nxdns): nxdns <version> serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53; 1 upstream(s); blocklist generation 1
|
||||
info(web_server): web interface listening on 0.0.0.0:8080
|
||||
```
|
||||
|
||||
Every later start on an unchanged file reports `reconciled
|
||||
'/etc/nxdns/config.zon': no changes` and writes nothing to the database.
|
||||
|
||||
Confirm it answers and that the admin interface is up:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -154,11 +154,11 @@ in step 5, and step 4 has to write a file into it before then. systemd does not
|
||||
mind finding the directory already there; it adjusts the mode and ownership to
|
||||
what the unit asks for.
|
||||
|
||||
## 4. Write the seed configuration
|
||||
## 4. Write the configuration
|
||||
|
||||
nxdns starts from an empty database only if a configuration file tells it what
|
||||
to forward to. Write `/etc/nxdns/config.zon`. The smallest file that starts is
|
||||
one group named `default` and one enabled upstream:
|
||||
nxdns will not start with nothing to forward to. Write `/etc/nxdns/config.zon`.
|
||||
The smallest file that starts is one group named `default` and one enabled
|
||||
upstream:
|
||||
|
||||
```zon
|
||||
.{
|
||||
@@ -213,36 +213,49 @@ The upstream probe sends a real query, so this needs working DNS on the host at
|
||||
the time you run it. Exit 2 means `check` found something to fix and printed
|
||||
every problem it found, not only the first.
|
||||
|
||||
The file seeds the database once. From the second start onwards it is ignored
|
||||
and the database is the configuration; see
|
||||
[the configuration model](../explanation/configuration-model.md) and
|
||||
[Upgrade nxdns](upgrade.md) for how to change settings after that.
|
||||
Now load it into the database:
|
||||
|
||||
Once the seed has been consumed — after step 6 confirms you can log in — the
|
||||
plaintext in it is dead weight that only carries risk. The seed's
|
||||
`web.password` is hashed into `web.password_hash` at import time and the
|
||||
plaintext is never stored; `nxdns export` writes `.password = ""` back out
|
||||
alongside the hash. Nothing downstream ever reads the plaintext again, so
|
||||
delete the file:
|
||||
```sh
|
||||
nxdns import /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
```
|
||||
info(migrations): config.db migrated from schema version 0 to 2
|
||||
imported /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
The plaintext password is hashed into `web.password_hash` and never stored as
|
||||
plaintext; `nxdns export` writes `.password = null` beside the hash. Nothing
|
||||
downstream reads the plaintext again, so once step 6 confirms you can log in you
|
||||
can delete the file:
|
||||
|
||||
```sh
|
||||
rm /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
Keep it only if you want the seed as a record of the intended starting
|
||||
configuration, and if you keep it, leave it at 0640 root:nxdns. Note that a
|
||||
kept seed is not a backup — `nxdns export` is
|
||||
(see [Back up and restore](back-up-and-restore.md)), and the export carries the
|
||||
password hash rather than the password.
|
||||
A kept file is not a backup — `nxdns export` is (see
|
||||
[Back up and restore](back-up-and-restore.md)), and the export carries the
|
||||
password hash rather than the password. If you keep it, leave it at 0640
|
||||
root:nxdns.
|
||||
|
||||
That is the **database mode** install, which is what the packaged unit runs:
|
||||
`ExecStart=/usr/local/bin/nxdns run`, no `--config`, so nothing reads a file
|
||||
after this step. Change settings afterwards through the admin interface, the
|
||||
API, or an export–edit–import cycle.
|
||||
|
||||
If you would rather keep `/etc/nxdns/config.zon` in git and have every restart
|
||||
converge onto it, do not delete the file — go to
|
||||
[Run in file mode](#run-in-file-mode) instead, and skip the `rm`.
|
||||
|
||||
> Verified on this host, with a scratch `--config` and `--data-dir` in place of
|
||||
> `/etc/nxdns` and `/var/lib/nxdns`: a seed written under umask 022 came out
|
||||
> 0644, `nxdns check --config` on it printed `OK: no problems found` with no
|
||||
> mode warning, and after `nxdns import` of that seed an `nxdns export` wrote
|
||||
> `.password = ""` next to a populated `.password_hash =
|
||||
> "$argon2id$v=19$..."`. The `chown`, `chmod` and `rm` lines above are the
|
||||
> ordinary root-owned-file operations and were not run against a real
|
||||
> `/etc/nxdns`, which this host does not have.
|
||||
> `/etc/nxdns` and `/var/lib/nxdns` — those two paths are the only difference
|
||||
> from the blocks above. A file written under umask 022 came out 0644,
|
||||
> `nxdns check --config` on it printed `OK: no problems found` with no mode
|
||||
> warning, `nxdns import` of it printed the migration line and `imported <path>`
|
||||
> and exited 0, and a following `nxdns export` wrote `.password = null` next to
|
||||
> a populated `.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…"`. The
|
||||
> `chown`, `chmod` and `rm` lines are ordinary root-owned-file operations and
|
||||
> were not run against a real `/etc/nxdns`, which this host does not have.
|
||||
|
||||
## 5. Start it
|
||||
|
||||
@@ -263,9 +276,16 @@ nxdns writes to stderr and systemd captures that into the journal; logging
|
||||
needs no further configuration. Port 53 is privileged, and the unit grants
|
||||
`CAP_NET_BIND_SERVICE` through `AmbientCapabilities`.
|
||||
|
||||
The unit does not restart the service after exit 2 or exit 64
|
||||
(`RestartPreventExitStatus=2 64`). Those are a wrong configuration and a wrong
|
||||
command line, and neither clears on a retry — restarting every two seconds until
|
||||
`StartLimitBurst` gives up would only bury the diagnostics that are already in
|
||||
the journal. `systemctl status nxdns` shows the failed state; fix the cause and
|
||||
start it again.
|
||||
|
||||
If the start fails, read [Troubleshoot nxdns](troubleshoot.md). The two common
|
||||
first-install failures are a port 53 already held by `systemd-resolved` and a
|
||||
seed file that does not parse.
|
||||
configuration file that does not parse.
|
||||
|
||||
## 6. Confirm it answers
|
||||
|
||||
@@ -276,7 +296,7 @@ dig @<server-ip> example.com A +short
|
||||
```
|
||||
|
||||
The admin interface is on port 8080 by default; log in with the password from
|
||||
the seed file. `http://<server-ip>:8080/api/health` reports upstream
|
||||
the configuration file. `http://<server-ip>:8080/api/health` reports upstream
|
||||
availability and disk state without a login.
|
||||
|
||||
> Not verified on this host as written: `<server-ip>` is a placeholder, and a
|
||||
@@ -287,6 +307,140 @@ availability and disk state without a login.
|
||||
> port returned 200. Only the address and the port differ from the lines
|
||||
> above.
|
||||
|
||||
## Run in file mode
|
||||
|
||||
In file mode `/etc/nxdns/config.zon` is the configuration: every start converges
|
||||
the database onto it, and the admin interface refuses configuration edits with a
|
||||
403 naming the file. Use it when you want the file in git and deployed by
|
||||
Ansible. Stay in database mode when you want the UI to be the way things change.
|
||||
|
||||
The packaged unit is flagless on purpose — it is correct as shipped, and a
|
||||
commented-out alternative `ExecStart` in a unit file is documentation
|
||||
masquerading as configuration. File mode is a drop-in.
|
||||
|
||||
### Adopt file mode on a box that is already running
|
||||
|
||||
Run these in order. **Stop first**, and do not skip that: any edit made through
|
||||
the UI between an export and the restart would be silently reverted by the first
|
||||
reconcile, and `nxdns check` against a live database refuses to grade it (below).
|
||||
|
||||
If you are arriving here from an upgrade, the binary must already be the new
|
||||
one before you export. An export written by 0.0.1 carries a `.password = ""`
|
||||
line this binary refuses; see
|
||||
[the order trap](upgrade.md#the-order-trap-export-with-the-new-binary-not-the-old-one).
|
||||
|
||||
```sh
|
||||
systemctl stop nxdns
|
||||
nxdns export --out /etc/nxdns/config.zon
|
||||
nxdns check --config /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
```
|
||||
wrote /etc/nxdns/config.zon
|
||||
checking configuration file /etc/nxdns/config.zon
|
||||
OK upstreams[0] https://cloudflare-dns.com
|
||||
OK: no problems found
|
||||
```
|
||||
|
||||
Then add the drop-in and start:
|
||||
|
||||
```sh
|
||||
mkdir -p /etc/systemd/system/nxdns.service.d
|
||||
cat > /etc/systemd/system/nxdns.service.d/file-mode.conf <<'EOF'
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/local/bin/nxdns run --config=/etc/nxdns/config.zon
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl start nxdns
|
||||
```
|
||||
|
||||
The empty `ExecStart=` is required. Without it systemd appends a second command
|
||||
to the list rather than replacing the first, and the unit tries to run nxdns
|
||||
twice.
|
||||
|
||||
The first start after adoption changes nothing, because the file was rendered
|
||||
from the database it is now governing:
|
||||
|
||||
```
|
||||
reconciled '/etc/nxdns/config.zon': no changes
|
||||
info(nxdns): authority: file (/etc/nxdns/config.zon)
|
||||
info(nxdns): nxdns <version> serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53; 1 upstream(s); blocklist generation 1
|
||||
```
|
||||
|
||||
`authority: file` is the line that confirms the drop-in took. Blocklists,
|
||||
compiled snapshots and client history all survive, and every later start on an
|
||||
unchanged file writes nothing either.
|
||||
|
||||
The file now carries `web.password_hash`, so restrict it the same way step 4
|
||||
does — `chown root:nxdns`, `chmod 0640`. The unit's `ReadOnlyPaths=/etc/nxdns`
|
||||
denies the service write access to that directory, so the process that reads the
|
||||
file cannot modify it.
|
||||
|
||||
### Change the configuration from now on
|
||||
|
||||
Edit the file, validate it, restart:
|
||||
|
||||
```sh
|
||||
$EDITOR /etc/nxdns/config.zon
|
||||
nxdns check --config /etc/nxdns/config.zon
|
||||
systemctl restart nxdns
|
||||
```
|
||||
|
||||
Make `nxdns check --config` the precondition of any Ansible handler that
|
||||
restarts nxdns. A file-mode start reads the file on **every** boot, so a bad
|
||||
push that skips its handler does not fail at deploy time — it detonates at the
|
||||
next power cut. Validating before restarting turns that into a failed deploy at
|
||||
noon.
|
||||
|
||||
The restart prints what it changed:
|
||||
|
||||
```
|
||||
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +0 ~2 -0;
|
||||
settings keys changed: dns.port web.port
|
||||
```
|
||||
|
||||
### Leave file mode
|
||||
|
||||
Remove the drop-in and restart. The database already holds the last reconciled
|
||||
state, so nothing else is needed and the server comes back serving the same
|
||||
configuration:
|
||||
|
||||
```sh
|
||||
rm /etc/systemd/system/nxdns.service.d/file-mode.conf
|
||||
systemctl daemon-reload
|
||||
systemctl restart nxdns
|
||||
```
|
||||
|
||||
```
|
||||
info(nxdns): authority: database
|
||||
```
|
||||
|
||||
> Verified on this host end to end, against a scratch `--data-dir` and a scratch
|
||||
> configuration path instead of `/var/lib/nxdns` and `/etc/nxdns`, on
|
||||
> unprivileged ports — this machine has neither of those directories, no root,
|
||||
> and no installed unit. Every `nxdns` line above was run and produced the output
|
||||
> shown, with only those paths and the port numbers in the `serving on` line
|
||||
> differing.
|
||||
>
|
||||
> The run: a database-mode instance was started, a blocklist source was added
|
||||
> through the API to make it UI-configured, then `nxdns check` against the live
|
||||
> database printed the uncheckpointed-log FAIL and exited 2 (which is why this
|
||||
> section stops the service first). After the stop, `nxdns export --out` wrote
|
||||
> the file, `nxdns check --config` on it exited 0 with `OK: no problems found`,
|
||||
> and the first file-mode start printed `reconciled '<path>': no changes` and
|
||||
> `authority: file (<path>)`. A second file-mode start printed `no changes`
|
||||
> again and loaded the 3096006-byte compiled blocklist from disk with no
|
||||
> download. Dropping the flag printed `authority: database` and served the same
|
||||
> configuration.
|
||||
>
|
||||
> The `systemctl`, `mkdir`, `cat > …/file-mode.conf` and `rm` lines need root and
|
||||
> an installed unit and were **not** run. What was checked instead:
|
||||
> `systemd-analyze verify` on `deploy/systemd/nxdns.service` with those exact two
|
||||
> `ExecStart` lines appended, which reported only the usual
|
||||
> `Command /usr/local/bin/nxdns is not executable` for the absent binary and
|
||||
> nothing about the override.
|
||||
|
||||
## Raspberry Pi 5
|
||||
|
||||
The Pi 5 is aarch64. Nothing about the procedure changes except which tarball
|
||||
|
||||
@@ -11,7 +11,7 @@ data directory is `/var/lib/nxdns` and the web port is 8080.
|
||||
|
||||
## 1. Set the password
|
||||
|
||||
Put it in the seed configuration file, under `web`:
|
||||
Put it in the configuration file, under `web`:
|
||||
|
||||
```zon
|
||||
.{
|
||||
@@ -21,17 +21,60 @@ Put it in the seed configuration file, under `web`:
|
||||
}
|
||||
```
|
||||
|
||||
At import time the plaintext is hashed with argon2id into `web.password_hash`
|
||||
and discarded. It becomes no database row and appears in no log line. Setting
|
||||
both `password` and `password_hash` in one file is refused:
|
||||
The plaintext is hashed with argon2id into `web.password_hash` and discarded. It
|
||||
becomes no database row and appears in no log line. Setting both `password` and
|
||||
`password_hash` in one file is refused:
|
||||
|
||||
```
|
||||
web.password: password and password_hash are both set; ambiguity in a security setting is refused
|
||||
import failed: PasswordAndHashBothSet
|
||||
```
|
||||
|
||||
The seed file is read only while the database is empty. On a server that
|
||||
already has a database, use step 4 or step 5 instead.
|
||||
Applying that file — with `nxdns import`, or with a `nxdns run --config` start —
|
||||
announces the change:
|
||||
|
||||
```
|
||||
web authentication is now enabled
|
||||
```
|
||||
|
||||
### Absent, empty, and set are three different things
|
||||
|
||||
The two fields are optional, and the difference between leaving one out and
|
||||
setting it to `""` is the difference between keeping your password and removing
|
||||
it:
|
||||
|
||||
| The file says | Effect on the stored password |
|
||||
| --- | --- |
|
||||
| Neither field | Nothing. It stays exactly as it was. |
|
||||
| `.password = "…"` | Installs that password. Unchanged plaintext keeps the existing hash rather than re-hashing it. |
|
||||
| `.password = ""` | Refused. |
|
||||
| `.password_hash = "$argon2id$…"` | Installs that hash, for example from an export. |
|
||||
| `.password_hash = ""` | **Removes the password.** Authentication is off. |
|
||||
|
||||
Absence has to mean "keep", because the alternative is a foot-gun with a live
|
||||
round in it. An export carries the full PHC string, which is long and ugly, and
|
||||
sooner or later someone trims that line out of a file before committing it —
|
||||
meaning "leave the password alone". If absence meant "no password", that edit
|
||||
would open the admin interface to the whole LAN without a word.
|
||||
|
||||
So removing the password takes the explicit empty string:
|
||||
|
||||
```
|
||||
web authentication is now disabled
|
||||
```
|
||||
|
||||
And an empty plaintext is refused outright, because hashing the empty string
|
||||
would switch authentication *on* while making every login impossible — the login
|
||||
handler rejects empty passwords:
|
||||
|
||||
```
|
||||
FAIL web.password: password is set to the empty string; omit the field to keep the stored password, or set password_hash = "" to disable authentication
|
||||
```
|
||||
|
||||
Which of steps 4 and 5 applies to your server depends on its authority. Under
|
||||
`nxdns run --config FILE` the file is the password: edit it and restart, and the
|
||||
API refuses the change with a 403. Under bare `nxdns run` the database holds it,
|
||||
and step 4 or step 5 is how it moves.
|
||||
|
||||
## 2. Log in
|
||||
|
||||
@@ -63,7 +106,7 @@ The session token comes back in a `Set-Cookie` header, not in the body. In the
|
||||
jar it looks like this (value redacted here):
|
||||
|
||||
```
|
||||
#HttpOnly_127.0.0.1 FALSE / FALSE 1785770178 nxdns_session <redacted>
|
||||
#HttpOnly_127.0.0.1 FALSE / FALSE 1786559938 nxdns_session <redacted>
|
||||
```
|
||||
|
||||
The cookie is named `nxdns_session` and carries `HttpOnly; SameSite=Lax;
|
||||
@@ -123,6 +166,9 @@ already is.
|
||||
|
||||
## 4. Change the password on a running server
|
||||
|
||||
This is a database-mode procedure. In file mode `PUT /api/settings` answers 403
|
||||
naming the file; edit `web.password` there and restart instead.
|
||||
|
||||
Send the new one to `PUT /api/settings` as `web.password`. The response is the
|
||||
full settings document; `password` is write-only and `password_hash` is neither
|
||||
readable nor directly writable, so neither value comes back.
|
||||
@@ -161,7 +207,7 @@ Log back in with the new password. That is the whole rotation.
|
||||
|
||||
If you have lost the password, the admin interface cannot help — go through the
|
||||
database instead. Export, edit, import. `nxdns export` always writes
|
||||
`.password = ""` and carries the hash, so an exported file re-imports without
|
||||
`.password = null` and carries the hash, so an exported file re-imports without
|
||||
anyone knowing the password. To install a new one, put it in `.password` and
|
||||
clear `.password_hash`:
|
||||
|
||||
@@ -169,11 +215,22 @@ clear `.password_hash`:
|
||||
nxdns export --data-dir /tmp/nxdns-lab/data --out /tmp/nxdns-lab/rekeyed.zon
|
||||
```
|
||||
|
||||
Edit the `web` section of `/tmp/nxdns-lab/rekeyed.zon` so it reads:
|
||||
Edit the `web` section of `/tmp/nxdns-lab/rekeyed.zon`: set `.password` to the
|
||||
new value and **delete the `.password_hash` line entirely**, so the `web` block
|
||||
carries one password field and not two:
|
||||
|
||||
```zon
|
||||
.password = "offline-password",
|
||||
.password_hash = "",
|
||||
```
|
||||
|
||||
Deleting the line is the part to get right. Setting `.password_hash = ""`
|
||||
alongside a plaintext password does not clear the way for it — an empty string
|
||||
is a present value meaning "no password", so the file then states two
|
||||
contradictory things and is refused:
|
||||
|
||||
```
|
||||
FAIL web.password: password and password_hash are both set; ambiguity in a security setting is refused
|
||||
import failed: PasswordAndHashBothSet
|
||||
```
|
||||
|
||||
Stop the server before importing. `import` rewrites the stored hash underneath a
|
||||
@@ -184,14 +241,17 @@ terminal stops it, and it goes back up with the same command:
|
||||
|
||||
```sh
|
||||
# Ctrl-C the `nxdns run` terminal, or `kill` its pid from another shell
|
||||
nxdns import /tmp/nxdns-lab/rekeyed.zon --force --data-dir /tmp/nxdns-lab/data
|
||||
nxdns run --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon
|
||||
nxdns import /tmp/nxdns-lab/rekeyed.zon --data-dir /tmp/nxdns-lab/data
|
||||
nxdns run --data-dir /tmp/nxdns-lab/data
|
||||
```
|
||||
|
||||
```
|
||||
imported /tmp/nxdns-lab/rekeyed.zon
|
||||
```
|
||||
|
||||
No flag is needed: replacing a password edits a settings value and deletes no
|
||||
rows.
|
||||
|
||||
On a real install the stop and start are `systemctl stop nxdns` and
|
||||
`systemctl start nxdns` around the same `import` — **not verified on this
|
||||
host**, which has no installed nxdns systemd unit (`systemctl status nxdns`
|
||||
@@ -202,7 +262,7 @@ Once it is back up the old password is refused and the new one works:
|
||||
|
||||
```sh
|
||||
curl -sS -X POST http://127.0.0.1:8451/api/auth/login \
|
||||
-H 'content-type: application/json' -d '{"password":"a-new-password"}' \
|
||||
-H 'content-type: application/json' -d '{"password":"lab-password"}' \
|
||||
-w ' (old password, http %{http_code})\n'
|
||||
curl -sS -c /tmp/nxdns-lab/c5.txt -X POST http://127.0.0.1:8451/api/auth/login \
|
||||
-H 'content-type: application/json' -d '{"password":"offline-password"}' \
|
||||
@@ -217,19 +277,19 @@ curl -sS -b /tmp/nxdns-lab/c5.txt -o /dev/null -w 'stats: %{http_code}\n' \
|
||||
stats: 200
|
||||
```
|
||||
|
||||
The next export shows the new hash and an empty `password` again:
|
||||
The next export shows the new hash and a null `password` again:
|
||||
|
||||
```sh
|
||||
nxdns export --data-dir /tmp/nxdns-lab/data | grep password
|
||||
```
|
||||
|
||||
```
|
||||
.password = "",
|
||||
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$kvlRj1tdGul3MlfbvzLncLKWirNpJRJ3howFA9/ysgg$7elW7PPQ3WXHwI4YOmOpZ/1KNEQo7ZDLRJhnYOPMjqw",
|
||||
.password = null,
|
||||
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$xqzK66LgiWGvyCmCl6ZRa3GHH0nS5qZnRgVfWmeGadc$1mafhflKFIg3vcHjJaDMAXGQiOjtym2UADZsPW1xkfw",
|
||||
```
|
||||
|
||||
`--force` is required because the database already holds configuration. See
|
||||
[back up and restore](back-up-and-restore.md).
|
||||
See [back up and restore](back-up-and-restore.md) for when `import` does need
|
||||
`--allow-delete`.
|
||||
|
||||
## What happens with no password set
|
||||
|
||||
@@ -279,6 +339,11 @@ interface at the very least, and preferably set a password.
|
||||
[configuration reference](../reference/configuration.md); the routes are in
|
||||
the [API reference](../reference/api.md).
|
||||
|
||||
Every command on this page was executed on this host as written, except the
|
||||
`systemctl` stop and start named in step 5 and marked **not verified on this
|
||||
host** there.
|
||||
Every command on this page was executed on this host as written, against the
|
||||
lab described at the top, except the `systemctl` stop and start named in step 5
|
||||
and marked **not verified on this host** there. That includes the whole of
|
||||
steps 2 to 5, re-run for this revision: the login, logout and rate-limit
|
||||
transcripts reproduced exactly as printed, the both-set refusal in step 5 was
|
||||
reproduced by leaving `.password_hash = ""` in the file, and the rekey then
|
||||
succeeded once that line was deleted. The cookie jar's expiry timestamp is the
|
||||
one that run produced and will differ on yours.
|
||||
|
||||
+105
-30
@@ -37,10 +37,22 @@ checked on its first line.
|
||||
**Fixes by cause.**
|
||||
|
||||
- `NoUsableUpstreams` — the database has no enabled upstream. On a fresh
|
||||
install this means the seed file was missing or in the wrong place; the start
|
||||
log says `no configuration file at '/etc/nxdns/config.zon'; using the
|
||||
database as it is`. Write the seed file and start again against the still
|
||||
empty database, or `nxdns import <file> --force`.
|
||||
install in database mode this is simply an empty database, and the run says
|
||||
what to do about it on the next line:
|
||||
|
||||
```
|
||||
nxdns run failed: NoUsableUpstreams
|
||||
run `nxdns check` to see the configuration in full
|
||||
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
|
||||
```
|
||||
|
||||
Write a configuration file and take either exit: `nxdns import <file>` to load
|
||||
it into the database once, or add `--config <file>` to `ExecStart` to make the
|
||||
file the configuration from then on.
|
||||
- `ManagedConfigUnreadable` — the service runs `run --config FILE` and that file
|
||||
is missing or the process may not read it. The path is in the FAIL line above
|
||||
the failure. File mode never falls back to the database, on purpose: a
|
||||
fallback would turn a bad deploy into a silently stale configuration.
|
||||
- `BadCertificate` — a DoH or DoT listener is enabled and its certificate or
|
||||
key is unreadable, too large, unparseable, or the key does not belong to the
|
||||
certificate. `run` names both paths before it exits:
|
||||
@@ -79,10 +91,10 @@ checked on its first line.
|
||||
- `BadBindAddress` — `dns.bind_ipv4` or `dns.bind_ipv6` is not an address of
|
||||
that family.
|
||||
|
||||
## A seed file you just wrote is rejected
|
||||
## A configuration file you just wrote is rejected
|
||||
|
||||
**Symptom.** A first start against an empty database prints the validation
|
||||
problem and stops with exit 2:
|
||||
**Symptom.** `nxdns run --config`, `nxdns check --config` or `nxdns import`
|
||||
prints the validation problem and stops with exit 2:
|
||||
|
||||
```
|
||||
FAIL groups: no group named 'default'; every unknown client is assigned to it
|
||||
@@ -98,7 +110,7 @@ nxdns run failed: ParseZon
|
||||
run `nxdns check` to see the configuration in full
|
||||
```
|
||||
|
||||
So does a seed file whose upstream list is empty or all disabled:
|
||||
So does a file whose upstream list is empty or all disabled:
|
||||
|
||||
```
|
||||
FAIL upstreams: at least one upstream must be enabled
|
||||
@@ -106,9 +118,9 @@ nxdns run failed: NoUpstreams
|
||||
run `nxdns check` to see the configuration in full
|
||||
```
|
||||
|
||||
`NoUpstreams` from a seed file is not the same fault as `NoUsableUpstreams`
|
||||
above: the first is a file `run` refused, the second is a database `run`
|
||||
accepted and found empty. Both are exit 2.
|
||||
`NoUpstreams` from a file is not the same fault as `NoUsableUpstreams` above:
|
||||
the first is a file `run` refused, the second is a database `run` accepted and
|
||||
found empty. Both are exit 2.
|
||||
|
||||
**Diagnosis.** Run the same file through `check`, which reports the same
|
||||
problems and exits 2:
|
||||
@@ -117,11 +129,21 @@ problems and exits 2:
|
||||
nxdns check --config /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
**Fix.** Correct the file the diagnostics name and start again. The database is
|
||||
still empty after a failed seed, so the next start re-reads the file. The exit
|
||||
code no longer depends on which command read the file: all three of these files
|
||||
were run through `run`, `check` and `import` here, and every one of the nine
|
||||
combinations exited 2 with the same diagnostic.
|
||||
**Fix.** Correct the file the diagnostics name and start again. Nothing was
|
||||
applied — a file-mode reconcile happens in one transaction that rolls back, and
|
||||
a failed `import` leaves the database untouched. The exit code does not depend
|
||||
on which command read the file: all three of these files were run through `run`,
|
||||
`check` and `import` here, and every one of the nine combinations exited 2 with
|
||||
the same diagnostic.
|
||||
|
||||
Under the shipped systemd unit an exit 2 stops the service rather than
|
||||
restarting it (`RestartPreventExitStatus=2 64`), so the journal holds the
|
||||
diagnostics instead of drowning them in a restart loop. `systemctl start nxdns`
|
||||
once the file is fixed.
|
||||
|
||||
Make `nxdns check --config <file>` the precondition in whatever pushes the file.
|
||||
In file mode every boot reads it, so an unvalidated bad push does not fail at
|
||||
deploy time — it fails at the next restart, which may be a power cut at 3am.
|
||||
|
||||
## `nxdns check` fails on a server that is running fine
|
||||
|
||||
@@ -210,10 +232,11 @@ startup cycle, not a fix.
|
||||
## The container restarts in a loop
|
||||
|
||||
**Symptom.** `docker compose ps` shows the container restarting, and the log is
|
||||
one line repeated:
|
||||
the same failure repeated. Docker has no start limit, so this goes on forever.
|
||||
|
||||
```
|
||||
nxdns run failed: AccessDenied
|
||||
FAIL /etc/nxdns/config.zon: not readable
|
||||
nxdns run failed: ManagedConfigUnreadable
|
||||
```
|
||||
|
||||
**Diagnosis.**
|
||||
@@ -223,10 +246,10 @@ docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}} restarts={{.Restar
|
||||
stat -c '%a %u:%g %n' deploy/docker/etc-nxdns/config.zon
|
||||
```
|
||||
|
||||
Exit 1 with `AccessDenied` means the container could not read the seed file.
|
||||
The container runs as uid 65532 and `/etc/nxdns` is mounted read-only, so a
|
||||
file at mode 0600 owned by your own uid is unreadable to it and the container
|
||||
cannot repair it.
|
||||
Exit 2 naming the configuration path means the container could not read the
|
||||
file the shipped `command:` makes its configuration. The container runs as uid
|
||||
65532 and `/etc/nxdns` is mounted read-only, so a file at mode 0600 owned by
|
||||
your own uid is unreadable to it and the container cannot repair it.
|
||||
|
||||
**Fix.** Either make the file world-readable, when it holds no secret:
|
||||
|
||||
@@ -241,14 +264,65 @@ chown 65532:65532 deploy/docker/etc-nxdns/config.zon
|
||||
chmod 0600 deploy/docker/etc-nxdns/config.zon
|
||||
```
|
||||
|
||||
The 0644 path was verified here, including the recovery: after the `chmod` the
|
||||
container started and answered queries. The `chown` needs root and was not run
|
||||
here.
|
||||
The 0644 path was verified against an earlier revision of this page, including
|
||||
the recovery: after the `chmod` the container started and answered queries. The
|
||||
`chown` needs root and was not run here.
|
||||
|
||||
A container that exits 2 instead — `nxdns run failed: NoUsableUpstreams` after
|
||||
`no configuration file at '/etc/nxdns/config.zon'` — has no seed file at all on
|
||||
a fresh volume. Create `deploy/docker/etc-nxdns/config.zon` and bring it up
|
||||
again; see [Install with Docker](install-with-docker.md).
|
||||
`FAIL /etc/nxdns/config.zon: no such file` instead of `not readable` means there
|
||||
is no configuration file at all. Create `deploy/docker/etc-nxdns/config.zon` and
|
||||
bring it up again; see [Install with Docker](install-with-docker.md).
|
||||
|
||||
A container that exits 2 with `NoUsableUpstreams` is in database mode — the
|
||||
`command:` line naming `--config` was removed — on a volume whose database is
|
||||
still empty. Load one and bring it back up:
|
||||
|
||||
```sh
|
||||
docker compose -f deploy/docker/compose.yaml run --rm nxdns import /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
> Not re-run on this host: staging a release image needs `zig build dist`, which
|
||||
> could not run here while the web bundle was mid-rebuild by other work in the
|
||||
> same checkout. The failure text quoted above is what the same binary prints
|
||||
> outside a container, which was reproduced here, with the container's paths.
|
||||
|
||||
## The admin interface refuses an edit with 403
|
||||
|
||||
**Symptom.** Saving anything in the admin interface fails, and the API answers:
|
||||
|
||||
```json
|
||||
{"error":"configuration is managed by /etc/nxdns/config.zon; edit the file and restart"}
|
||||
```
|
||||
|
||||
This is not a fault. The service runs `nxdns run --config`, which makes that
|
||||
file the configuration, and configuration writes through the API are refused so
|
||||
the file and the running server cannot drift apart.
|
||||
|
||||
**Diagnosis.** The start log names the authority:
|
||||
|
||||
```sh
|
||||
journalctl -u nxdns | grep 'authority:'
|
||||
```
|
||||
|
||||
```
|
||||
info(nxdns): authority: file (/etc/nxdns/config.zon)
|
||||
```
|
||||
|
||||
**Fix.** Edit the file, validate it, restart:
|
||||
|
||||
```sh
|
||||
$EDITOR /etc/nxdns/config.zon
|
||||
nxdns check --config /etc/nxdns/config.zon
|
||||
systemctl restart nxdns
|
||||
```
|
||||
|
||||
Or, if you want the interface to be how this box is configured, leave file mode:
|
||||
drop `--config` from `ExecStart` and restart. The database already holds the
|
||||
last reconciled state, so nothing is lost. See
|
||||
[Run in file mode](install-with-systemd.md#run-in-file-mode).
|
||||
|
||||
Pausing blocking, refreshing blocklists and reloading certificates are not
|
||||
configuration and keep working in file mode. Deleting a client works too, unless
|
||||
the file names that client's address.
|
||||
|
||||
## The container cannot reach its upstreams
|
||||
|
||||
@@ -324,7 +398,8 @@ window of unfiltered answers.
|
||||
A generation number with nothing being blocked is a different problem: the
|
||||
snapshot loaded but has no sources in it. The line
|
||||
`blocklist snapshot generation 1: 0 of 0 sources loaded` says exactly that. Add
|
||||
a source in the admin interface, or in the seed file before the first start.
|
||||
a source in the admin interface, or a `blocklist_sources` entry to the
|
||||
configuration file with a `group_sources` link naming a group.
|
||||
|
||||
## A database stamped by a newer binary
|
||||
|
||||
|
||||
+179
-27
@@ -21,6 +21,105 @@ Upgrading a build you made yourself is the last section of this page.
|
||||
> shapes and the verification commands are covered by
|
||||
> [Verify a release](verify-a-release.md), which says what was probed and how.
|
||||
|
||||
## Breaking change: `run --config` now means file authority
|
||||
|
||||
**Read this before upgrading if anything on your box passes `--config` to
|
||||
`nxdns run`** — a systemd drop-in, a wrapper script, or a `command:` in a
|
||||
compose file.
|
||||
|
||||
`run --config FILE` used to mean *seed once*: the file was read only while the
|
||||
database was still empty, and ignored on every start after that. It now means
|
||||
*the file is the configuration*: every start reconciles the database onto it.
|
||||
|
||||
For a box that was seeded once and then configured through the admin interface,
|
||||
the first start after the upgrade converges the database back to that old seed
|
||||
file. **Every change made through the UI since seeding is deleted.**
|
||||
|
||||
There are two ways out, and you pick before you restart:
|
||||
|
||||
- **Keep the database.** Drop the flag. `nxdns run` with no `--config` serves
|
||||
the database exactly as it did before, and nothing reads a file. This is the
|
||||
right answer if the UI is how you change things.
|
||||
- **Adopt file mode cleanly.** Install the new binary, stop the service, export
|
||||
the current database over the file path, check it, then start with the flag.
|
||||
The first reconcile is then a no-op, because the file was rendered from the
|
||||
database it governs. **Install the new binary first** — see the order trap
|
||||
below. The full procedure is
|
||||
[Adopt file mode](install-with-systemd.md#adopt-file-mode-on-a-box-that-is-already-running).
|
||||
|
||||
`nxdns check --config FILE` is unchanged: it graded that file before and it
|
||||
grades that file now.
|
||||
|
||||
### The order trap: export with the new binary, not the old one
|
||||
|
||||
Take the export **after** you have replaced the binary, with the service
|
||||
stopped. Exporting first — the instinctive order, and the one step 1 of this
|
||||
page tells you to take for a backup — produces a file the new binary refuses.
|
||||
|
||||
A 0.0.1 `nxdns export` writes both fields:
|
||||
|
||||
```zon
|
||||
.password = "",
|
||||
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…",
|
||||
```
|
||||
|
||||
An empty `password_hash` used to mean "unset". It now means "disable
|
||||
authentication", so it is a *present* value — and a file that carries both
|
||||
fields states two different things about the password and is refused:
|
||||
|
||||
```
|
||||
FAIL web.password: password and password_hash are both set; ambiguity in a security setting is refused
|
||||
FAIL web.password: password is set to the empty string; omit the field to keep the stored password, or set password_hash = "" to disable authentication
|
||||
nxdns run failed: PasswordAndHashBothSet
|
||||
```
|
||||
|
||||
The old `nxdns check` passes that file, because the old binary agreed with the
|
||||
old rule. So the failure lands at the first start after the upgrade, with the
|
||||
resolver stopped and the unit refusing to retry it
|
||||
(`RestartPreventExitStatus=2 64`). A new `nxdns export` writes
|
||||
`.password = null` instead and has no such problem.
|
||||
|
||||
**If you already have an old export you want to adopt**, you do not need to
|
||||
redo it. Delete the empty-password line and the file is valid:
|
||||
|
||||
```sh
|
||||
sed -i '/^ \.password = "",$/d' /etc/nxdns/config.zon
|
||||
nxdns check --config /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
Keep the `.password_hash` line — that is the password, and deleting it as well
|
||||
would leave the file saying nothing about authentication, which means "keep
|
||||
whatever is stored" rather than anything you would notice.
|
||||
|
||||
The same trap has nothing to do with file mode as such: it is any 0.0.1 export
|
||||
fed to the new binary, so it also applies to a restore through `nxdns import`.
|
||||
Backups taken with 0.0.1 need that one line removed before they will load.
|
||||
|
||||
> Verified on this host, with one substitution stated: the repository has no
|
||||
> 0.0.1 binary to hand, so the old export was **simulated** by taking a current
|
||||
> `nxdns export` and rewriting `.password = null` to `.password = ""`, which is
|
||||
> the one byte-level difference between the two formats. Against that file,
|
||||
> `nxdns check --config` printed both FAIL lines above and exited 2, and
|
||||
> `nxdns run --config` printed them and failed `PasswordAndHashBothSet`. After
|
||||
> the `sed` above, `nxdns check --config` exited 0 with `OK: no problems found`
|
||||
> and `nxdns import` of the same file exited 0, keeping the hash. The claim
|
||||
> about what 0.0.1's `export` emitted is read from that release's source —
|
||||
> `git show v0.0.1:src/config/export.zig` line 71 is `cfg.web.password = "";` —
|
||||
> not from running that binary.
|
||||
|
||||
A database-mode install that never passed `--config` needs nothing. Under
|
||||
Docker, a fresh database-mode install must either take the new compose file or
|
||||
run `import` once — see
|
||||
[Database mode in Docker](install-with-docker.md#database-mode-in-docker-instead).
|
||||
|
||||
Two smaller renames in the same release: `nxdns import --force` is now
|
||||
`--allow-delete`, and it is required only when the file's diff would delete
|
||||
rows rather than whenever the database is non-empty. `nxdns check` no longer
|
||||
falls back to a default file path when there is no database; it reports the
|
||||
absent database and names the two ways to get one.
|
||||
|
||||
There is no schema migration in this change.
|
||||
|
||||
## 1. Take an export first
|
||||
|
||||
There is no downgrade path, so the export is what you fall back to:
|
||||
@@ -33,6 +132,13 @@ nxdns export --out /some/backup/nxdns-config.zon
|
||||
command relies on the default `--data-dir /var/lib/nxdns` that a systemd
|
||||
install has.
|
||||
|
||||
This export is a fallback, not a file to deploy. If you are adopting file mode,
|
||||
take a *second* export after the binary swap and use that one — an export
|
||||
written by 0.0.1 carries a `.password = ""` line the new binary refuses, as
|
||||
[the order trap](#the-order-trap-export-with-the-new-binary-not-the-old-one)
|
||||
explains. The same line has to come out of this backup before the new binary
|
||||
will import it.
|
||||
|
||||
> Verified on this host with both paths substituted, since it has neither
|
||||
> `/var/lib/nxdns` nor `/some/backup`. `SCRATCH` below is a scratch directory,
|
||||
> and its `data/` was populated beforehand with `nxdns import`:
|
||||
@@ -131,9 +237,11 @@ NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml pull
|
||||
NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml up -d
|
||||
```
|
||||
|
||||
Compose recreates the container against the same `nxdns-data` volume. The seed
|
||||
file in `etc-nxdns` is not read again; the database in the volume is the
|
||||
configuration.
|
||||
Compose recreates the container against the same `nxdns-data` volume. What
|
||||
happens to the file in `etc-nxdns` depends on the `command:` in your compose
|
||||
file: with the shipped `run --config=/etc/nxdns/config.zon` the file is the
|
||||
configuration and the restart reconciles onto it; without it, the database in
|
||||
the volume is the configuration and the file is read by nothing.
|
||||
|
||||
Set `NXDNS_VERSION` on both lines, or export it. Without it the compose file
|
||||
falls back to `:latest`, and `pull` and `up` could then land on different
|
||||
@@ -184,11 +292,12 @@ OK: no problems found
|
||||
> zig 0.16.0
|
||||
> ```
|
||||
>
|
||||
> Against a running server whose database had just been migrated and seeded,
|
||||
> `nxdns check --data-dir` printed the uncheckpointed-log line above and exited
|
||||
> 2, while `nxdns export --out` followed by `nxdns check --config` on the result
|
||||
> exited 0 with `OK: no problems found`. The `dig` line was not run in this
|
||||
> round: nothing is listening on 127.0.0.1:53 here, and port 53 needs root.
|
||||
> Against a running server whose database had just taken a configuration write
|
||||
> through the API, `nxdns check --data-dir` printed the uncheckpointed-log line
|
||||
> above and exited 2, while `nxdns export --out` followed by
|
||||
> `nxdns check --config` on the result exited 0 with `OK: no problems found`.
|
||||
> Both were re-run for this revision. The `dig` line was not run in this round:
|
||||
> nothing is listening on 127.0.0.1:53 here, and port 53 needs root.
|
||||
|
||||
## What happens to the database
|
||||
|
||||
@@ -235,46 +344,89 @@ nxdns run failed: SchemaTooNew
|
||||
That run exits 1. Recovering means importing the export you took in step 1 into
|
||||
a fresh data directory with the older binary.
|
||||
|
||||
### Rolling back from file mode
|
||||
|
||||
Putting an older binary back needs no unit edit. The old binary accepts
|
||||
`run --config` — it just reads it as the old seed-once flag — and against a
|
||||
database that already holds configuration it ignores the file entirely and
|
||||
serves the last state the new binary reconciled. So the service comes back up
|
||||
on the configuration it was running.
|
||||
|
||||
The consequence is worth stating plainly: **file edits stop applying.** The old
|
||||
binary will not re-read the file, so every change made to `config.zon` after the
|
||||
rollback does nothing at all, silently, until the newer binary is back. If you
|
||||
have to stay on the old binary, use `nxdns import` to apply file changes, or drop
|
||||
the flag so the invocation matches what the binary actually does.
|
||||
|
||||
The schema note above still governs: a database stamped by a newer binary
|
||||
refuses to open, whatever mode either binary runs in.
|
||||
|
||||
## Changing settings, not the binary
|
||||
|
||||
An upgrade never re-reads `/etc/nxdns/config.zon`. After the first successful
|
||||
seed the file is ignored, and the start log says so:
|
||||
How you change a setting depends on which authority the service runs under.
|
||||
`nxdns run` in `ExecStart` means the database; `nxdns run --config FILE` means
|
||||
the file. The start log names it either way:
|
||||
|
||||
```
|
||||
info(config_bootstrap): configuration file ignored; the database is already configured
|
||||
info(nxdns): authority: database
|
||||
info(nxdns): authority: file (/etc/nxdns/config.zon)
|
||||
```
|
||||
|
||||
Change settings through the admin interface, through the API, or with an
|
||||
export–edit–import cycle against a stopped server:
|
||||
**In file mode**, edit the file, validate it, restart. The admin interface will
|
||||
refuse the change with a 403 naming the file, so there is nothing to get wrong:
|
||||
|
||||
```sh
|
||||
$EDITOR /etc/nxdns/config.zon
|
||||
nxdns check --config /etc/nxdns/config.zon
|
||||
systemctl restart nxdns
|
||||
```
|
||||
|
||||
**In database mode**, change settings through the admin interface, through the
|
||||
API, or with an export–edit–import cycle against a stopped server:
|
||||
|
||||
```sh
|
||||
nxdns export --out config-backup.zon
|
||||
$EDITOR config-backup.zon
|
||||
systemctl stop nxdns
|
||||
nxdns import config-backup.zon --force
|
||||
nxdns import config-backup.zon
|
||||
systemctl start nxdns
|
||||
```
|
||||
|
||||
`--force` is required here. A plain `import` into a database that already holds
|
||||
configuration fails with `import failed: DatabaseNotEmpty` and exits 2, so it
|
||||
cannot clobber a configured server by accident. What counts is what an operator
|
||||
set: client rows the DNS path materialised from traffic never trigger the
|
||||
refusal on their own.
|
||||
`import` needs no flag to add rows or to edit them. It needs `--allow-delete`
|
||||
only when applying the file would delete rows the database holds — including the
|
||||
case where you renamed something, since changing a group's name or an upstream's
|
||||
URL is a delete and an insert to the engine, not an edit. The refusal names the
|
||||
tables and rolls back:
|
||||
|
||||
> Verified on this host for the two `nxdns` lines, against a populated scratch
|
||||
> data directory:
|
||||
```
|
||||
FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it
|
||||
import failed: DestructiveImport
|
||||
```
|
||||
|
||||
Stop the server first either way. `import` rewrites configuration underneath a
|
||||
process that read it at startup, and a running server picks up only some of it.
|
||||
|
||||
> Verified on this host against a populated scratch data directory, with
|
||||
> `--data-dir` pointing at it — that path is the only difference from the blocks
|
||||
> above:
|
||||
>
|
||||
> ```
|
||||
> $ nxdns import $SCRATCH/nxdns-config.zon --data-dir $SCRATCH/data
|
||||
> import failed: DatabaseNotEmpty
|
||||
> $ nxdns import $SCRATCH/etc/config.zon --data-dir $SCRATCH/dbmode
|
||||
> imported /…/config.zon
|
||||
> (exit 0)
|
||||
> $ nxdns import $SCRATCH/etc/smaller.zon --data-dir $SCRATCH/dbmode
|
||||
> FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it
|
||||
> import failed: DestructiveImport
|
||||
> (exit 2)
|
||||
> $ nxdns import $SCRATCH/nxdns-config.zon --data-dir $SCRATCH/data --force
|
||||
> imported /…/scratchpad/nxdns-config.zon
|
||||
> $ nxdns import $SCRATCH/etc/smaller.zon --data-dir $SCRATCH/dbmode --allow-delete
|
||||
> imported /…/smaller.zon
|
||||
> (exit 0)
|
||||
> ```
|
||||
>
|
||||
> The `systemctl stop`/`start` lines around them need root and an installed
|
||||
> service and were not run; `$EDITOR` is yours to run.
|
||||
> The first of those three is the additive case that needs no flag; the second
|
||||
> file replaced the upstream, which is an identity change and therefore a
|
||||
> delete. The `systemctl stop`/`start` lines need root and an installed service
|
||||
> and were not run; `$EDITOR` is yours to run.
|
||||
|
||||
## Upgrading to a build of your own
|
||||
|
||||
|
||||
+60
-106
@@ -8,41 +8,12 @@ Do this before you run the binary, not after. The whole point of the checksum
|
||||
file is that it is signed, so a tampered mirror cannot hand you a matching
|
||||
tarball and a matching checksum at the same time.
|
||||
|
||||
> Verification: no nxdns release exists yet. The repository has no tags, no
|
||||
> release page and no pushed image, so nothing on this page could be run against
|
||||
> a real release asset and no command here was pointed at
|
||||
> `git.mial.net/mokhtar/nxdns` with any expectation of success. Substitutes were
|
||||
> used, and every block says which one applies to it.
|
||||
>
|
||||
> The URL shapes were probed against `gitea.com`, a public instance of the same
|
||||
> Gitea series running `1.27.0+dev-652-g0571722545`, using `gitea/tea`, which
|
||||
> does have releases. `git.mial.net` reports `1.27.1`, and its
|
||||
> `/mokhtar/nxdns/releases/latest` answers 404 — no release to redirect to. On
|
||||
> `gitea/tea`, `releases/latest` answered 303 to the tag page of `v0.15.1`;
|
||||
> `releases/download/v0.15.1/checksums.txt` and
|
||||
> `releases/download/latest/checksums.txt` both answered 303 to the same stored
|
||||
> object and delivered the same 1,842-byte file under `-L`;
|
||||
> `releases/latest/download/checksums.txt` — GitHub's spelling — answered 404.
|
||||
>
|
||||
> The `gpg --verify` and `sha256sum -c` blocks were run on this host against
|
||||
> stand-in files: two random-byte files named like the release tarballs, an
|
||||
> `IMAGE-DIGEST.txt` holding one image reference, and a `SHA256SUMS.txt`
|
||||
> computed over the three, signed by a **throwaway demonstration key generated
|
||||
> for this page**. That key has the shape the real one will have — an ed25519
|
||||
> primary key plus a separate ed25519 signing subkey, with the signature made by
|
||||
> the subkey — so the `gpg --verify` output on this page has the two-fingerprint
|
||||
> structure a subkey-signed release produces. The fingerprints printed in those
|
||||
> transcripts are the throwaway key's, they are not the project's, and they will
|
||||
> not match anything you download. The only edit to that run's output is the
|
||||
> version in every filename, which became `<version>`.
|
||||
>
|
||||
> The container blocks were not run against nxdns — there is no published image.
|
||||
> The two `docker buildx imagetools inspect --format` shapes were run here
|
||||
> against `alpine:3.22` on Docker Hub, the base this project's builder stage
|
||||
> pins; the digest form printed
|
||||
> `sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce` and
|
||||
> the platform form printed a list. The `docker create`/`docker cp` comparison
|
||||
> was run against an image built from this checkout rather than a pulled one.
|
||||
> Verification: every command on this page was run on 2026-08-09 against the
|
||||
> published `v0.0.1` release, from a clean directory, with a clean `GNUPGHOME`
|
||||
> holding only the key fetched from keys.openpgp.org. Every transcript below is
|
||||
> that run's output. Where a block shows a failure — a `BAD signature`, a
|
||||
> `FAILED` hash — the failure was produced deliberately by tampering with a
|
||||
> copy of the real file, and the surrounding text says how.
|
||||
|
||||
## What a release contains
|
||||
|
||||
@@ -94,11 +65,8 @@ want for the placeholder:
|
||||
VERSION=<version>
|
||||
```
|
||||
|
||||
> Not verified against nxdns: there is no release to redirect to, so the first
|
||||
> block prints an empty line here and every URL built from it is a 404. The
|
||||
> exact two-command form was run against `gitea.com/gitea/tea`, a public
|
||||
> repository on Gitea `1.27.0+dev` that does have releases, and printed
|
||||
> `0.15.1`.
|
||||
> Verified: the two-command form, run against this repository, printed `0.0.1`
|
||||
> with `v0.0.1` published.
|
||||
|
||||
Pin the version in anything you script or automate. `latest` is convenient for
|
||||
a person at a terminal and a liability in a machine that upgrades itself.
|
||||
@@ -136,11 +104,9 @@ That is the Gitea spelling, and it is not GitHub's. `releases/latest/download/`
|
||||
position, as `releases/download/latest/`. The tarball filenames contain the
|
||||
version, so this alias never saves you from knowing it for those two.
|
||||
|
||||
> Not verified against nxdns: no release, so every URL above is a 404 today.
|
||||
> Both URL forms, including the 404 for GitHub's spelling, were exercised
|
||||
> against `gitea.com/gitea/tea` on Gitea `1.27.0+dev`; the versioned path and
|
||||
> the `latest` alias each answered 303 to the same stored object and delivered
|
||||
> the same 1,842-byte `checksums.txt` when the redirect was followed.
|
||||
> Verified against `v0.0.1`: all five assets downloaded through the versioned
|
||||
> path, `SHA256SUMS.txt` downloaded again through the `latest` alias and hashed
|
||||
> identical, and GitHub's spelling answered 404.
|
||||
|
||||
## 3. Check the signature over `SHA256SUMS.txt`
|
||||
|
||||
@@ -157,11 +123,9 @@ curl -fsSL https://keys.openpgp.org/vks/v1/by-fingerprint/A2061F6AB24DF2C0E92346
|
||||
gpg --import
|
||||
```
|
||||
|
||||
> Not verified: the key is not published yet. Run on this host, that URL
|
||||
> returned 404, and so did the `by-email` lookup for the same address. The
|
||||
> endpoint itself is live: the same `by-fingerprint` path returned 200 for an
|
||||
> unrelated key that is on keys.openpgp.org. Until this key is published there,
|
||||
> get it from a source you can check some other way.
|
||||
> Verified: the key is published, and that exact `curl | gpg --import` reported
|
||||
> `key 1509B54946D08A95: public key "Mokhtar Mial (pc) <mokhtar@mial.net>"
|
||||
> imported` into a clean `GNUPGHOME`.
|
||||
|
||||
Then verify:
|
||||
|
||||
@@ -170,22 +134,21 @@ gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
|
||||
```
|
||||
|
||||
```
|
||||
gpg: Signature made Fri 07 Aug 2026 10:11:12 PM CEST
|
||||
gpg: using EDDSA key 9D1EA241DAEA89E09381A21BDC27E8A3D53C32D6
|
||||
gpg: Good signature from "nxdns release signing (throwaway demonstration key) <demo@example.invalid>" [unknown]
|
||||
gpg: Signature made Sun 09 Aug 2026 01:26:42 AM CEST
|
||||
gpg: using EDDSA key 019D00DF8417EBFDA5471E5EF7319CC024FB5A96
|
||||
gpg: Good signature from "Mokhtar Mial (pc) <mokhtar@mial.net>" [unknown]
|
||||
gpg: WARNING: This key is not certified with a trusted signature!
|
||||
gpg: There is no indication that the signature belongs to the owner.
|
||||
Primary key fingerprint: 6643 13AA F527 DDAE 1C1E 516C A36F F8DA 4E6C 1C07
|
||||
Subkey fingerprint: 9D1E A241 DAEA 89E0 9381 A21B DC27 E8A3 D53C 32D6
|
||||
Primary key fingerprint: A206 1F6A B24D F2C0 E923 46FD 1509 B549 46D0 8A95
|
||||
Subkey fingerprint: 019D 00DF 8417 EBFD A547 1E5E F731 9CC0 24FB 5A96
|
||||
```
|
||||
|
||||
**Those two fingerprints and that user id belong to a throwaway key generated
|
||||
to produce this transcript.** They are not the project's, and what you see will
|
||||
carry the project's uid and the fingerprint in this page instead. The
|
||||
*structure* is what to read: three lines, not one. `using EDDSA key` and
|
||||
The *structure* is what to read: three lines, not one. `using EDDSA key` and
|
||||
`Subkey fingerprint` name the signing subkey that actually made the signature;
|
||||
`Primary key fingerprint` names the certificate it hangs off, and that is the
|
||||
one published above.
|
||||
one published above. The subkey fingerprint can change — a signing subkey is
|
||||
revoked and replaced on its own — but the primary fingerprint is the
|
||||
project's identity and stays.
|
||||
|
||||
Exit status 0, and `Good signature`. That warning is normal and is not a
|
||||
failure: it says you have not told GnuPG you believe the key belongs to the
|
||||
@@ -210,22 +173,18 @@ including one an attacker talked you into importing.
|
||||
A tampered `SHA256SUMS.txt` looks like this, and exits 1:
|
||||
|
||||
```
|
||||
gpg: Signature made Fri 07 Aug 2026 10:11:12 PM CEST
|
||||
gpg: using EDDSA key 9D1EA241DAEA89E09381A21BDC27E8A3D53C32D6
|
||||
gpg: BAD signature from "nxdns release signing (throwaway demonstration key) <demo@example.invalid>" [unknown]
|
||||
gpg: Signature made Sun 09 Aug 2026 01:26:42 AM CEST
|
||||
gpg: using EDDSA key 019D00DF8417EBFDA5471E5EF7319CC024FB5A96
|
||||
gpg: BAD signature from "Mokhtar Mial (pc) <mokhtar@mial.net>" [unknown]
|
||||
```
|
||||
|
||||
> Verified on this host. A throwaway ed25519 primary key was generated into a
|
||||
> temporary `GNUPGHOME`, an ed25519 **signing subkey** was added to it, and the
|
||||
> stand-in `SHA256SUMS.txt` was signed with `--local-user <subkey-fingerprint>!`
|
||||
> — the same construction the release workflow uses — so the transcripts above
|
||||
> are what a subkey-signed release actually prints, rather than what a key
|
||||
> signing with its primary would. The verification ran from a second
|
||||
> `GNUPGHOME` holding only that key's public half, which is why the `[unknown]`
|
||||
> trust marker and the warning are there rather than being written in by hand.
|
||||
> The second transcript is the same command after one newline was appended to
|
||||
> `SHA256SUMS.txt`. The `sed`/`tr` pipeline was run against that same output and
|
||||
> printed `664313AAF527DDAE1C1E516CA36FF8DA4E6C1C07`, the throwaway primary.
|
||||
> Verified against `v0.0.1`, from a clean `GNUPGHOME` holding only the imported
|
||||
> public key — which is why the `[unknown]` trust marker and the warning are
|
||||
> there rather than being written in by hand. The good-signature transcript is
|
||||
> the real release's; the `BAD signature` transcript is the same command
|
||||
> against a copy of `SHA256SUMS.txt` with one newline appended, and it exited
|
||||
> 1. The `sed`/`tr` pipeline printed
|
||||
> `A2061F6AB24DF2C0E92346FD1509B54946D08A95`, matching the fingerprint above.
|
||||
|
||||
## 4. Check the hashes
|
||||
|
||||
@@ -263,12 +222,11 @@ Check the signature before the hashes, not after. An attacker who can replace
|
||||
the tarball can replace `SHA256SUMS.txt` next to it; the signature is the only
|
||||
thing in the set they cannot forge.
|
||||
|
||||
> Verified on this host against the stand-in files: all three transcripts are
|
||||
> real `sha256sum` output over two random-byte files named like the release
|
||||
> tarballs plus an `IMAGE-DIGEST.txt` holding one image reference, with one
|
||||
> tarball deleted for the first two blocks and one byte appended to the other
|
||||
> for the third. Only the version in the filenames was replaced with
|
||||
> `<version>`.
|
||||
> Verified against `v0.0.1`: with both tarballs present, `sha256sum -c` printed
|
||||
> three `OK` lines. The three transcripts above are the same command over
|
||||
> copies of the real assets, with the aarch64 tarball absent for the first two
|
||||
> and one byte appended to the x86_64 tarball for the third. Only the version
|
||||
> in the filenames was replaced with `<version>`.
|
||||
|
||||
## 5. Look inside before extracting
|
||||
|
||||
@@ -294,7 +252,11 @@ tar -xzf nxdns-$VERSION-x86_64-linux-musl.tar.gz
|
||||
Zig version. The version has to match the tag you downloaded, and the commit
|
||||
has to match the commit the tag points at.
|
||||
|
||||
> Not verified on this host: there is no release tarball to list or extract.
|
||||
> Verified against `v0.0.1`: both tarballs listed exactly the one directory and
|
||||
> six files with the stated modes, no symlinks and no absolute or `..` paths,
|
||||
> and the extracted binary printed `nxdns 0.0.1
|
||||
> (3c2d0d41f04570038e805b759da4541e198eae17)` — the commit `v0.0.1` points at —
|
||||
> then `zig 0.16.0`.
|
||||
|
||||
## 6. Verify the container image
|
||||
|
||||
@@ -343,23 +305,13 @@ docker rm nxdns-verify
|
||||
sha256sum ./nxdns-from-image ./nxdns-$VERSION-x86_64-linux-musl/nxdns
|
||||
```
|
||||
|
||||
> Not verified against nxdns: no image is published, so no command here was run
|
||||
> against `git.mial.net/mokhtar/nxdns`. The two
|
||||
> `docker buildx imagetools inspect --format` shapes were run on this host
|
||||
> against `alpine:3.22` on Docker Hub — the digest form printed
|
||||
> `sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce`,
|
||||
> which is the digest this project's builder stage pins, and the platform form
|
||||
> printed `linux/amd64 unknown/unknown linux/arm unknown/unknown ...`. That
|
||||
> `unknown/unknown` is exactly what the paragraph above says nxdns's own index
|
||||
> must not contain: Alpine's index carries attestation entries, and nxdns's
|
||||
> build turns them off. Nothing was checked about how nxdns's index will
|
||||
> actually look.
|
||||
>
|
||||
> The `docker create` / `docker cp` / `sha256sum` comparison at the end was run
|
||||
> here against an image built from this checkout rather than a pulled one, and
|
||||
> the two hashes matched: the binary copied out of the image and
|
||||
> `zig-out/dist/stage/nxdns-<version>-x86_64-linux-musl/nxdns` were the same
|
||||
> file.
|
||||
> Verified against `v0.0.1`: the digest in `IMAGE-DIGEST.txt` and the digest
|
||||
> the `:0.0.1` tag resolves to were the same string
|
||||
> (`sha256:f2945fbf6c1e16509f0e33e3d62da9a9cd7dc706718d333ce4edf95c80dbb00e`,
|
||||
> and `:latest` resolved to it too), the platform form printed exactly
|
||||
> `linux/amd64 linux/arm64` with no attestation entries, `docker pull` of the
|
||||
> pinned reference succeeded, and the binary copied out of that pulled image
|
||||
> hashed identical to the `nxdns` in the x86_64 tarball.
|
||||
|
||||
## What the signature proves, and what it does not
|
||||
|
||||
@@ -431,14 +383,16 @@ release used. The Zig version is the second line of `nxdns version`, and both
|
||||
it and the Node version are pinned to exact patch releases at the top of
|
||||
`.gitea/workflows/gates.yml`, which is the workflow the release runs.
|
||||
|
||||
> Partly verified on this host. `zig build dist` and `sha256sum` on its output
|
||||
> were run to completion, with the version read out of `build.zig.zon`: `dist`
|
||||
> exited 0 and wrote the two tarballs, `SHA256SUMS` and the staged payloads
|
||||
> described above. `zig build verify-dist` was run on the result too and exited
|
||||
> 0. What could not be run is everything that needs a release: the clone, the
|
||||
> checkout and `git verify-tag` need a tag that does not exist, and there is no
|
||||
> published `SHA256SUMS.txt` to compare a local build against, so the comparison
|
||||
> this section is about has never been performed.
|
||||
> Verified against `v0.0.1`, and the result is the caveat above in action. The
|
||||
> whole recipe ran from a fresh clone: `git verify-tag v0.0.1` printed
|
||||
> `Good signature` under the same signing subkey as the release, and
|
||||
> `zig build dist` produced both tarballs. The hashes did **not** match the
|
||||
> published `SHA256SUMS.txt` — the binaries themselves already differ. The Zig
|
||||
> version matched the pin exactly; the Node version did not (24.14.1 against
|
||||
> the pinned 24.19.0) and the build path differed, two of the ordinary causes
|
||||
> listed above. That is a measurement of what an unpinned rebuild gives you,
|
||||
> not evidence of tampering: the signature, checksum and image checks earlier
|
||||
> on this page all passed against the same release.
|
||||
|
||||
## If a check fails
|
||||
|
||||
|
||||
+137
-59
@@ -31,6 +31,9 @@ below carries all 56 of its entries.
|
||||
- Mutations to groups, blocklists, rules, local records, forward zones, clients
|
||||
and client prefixes take effect live. Upstreams and `/api/settings` are
|
||||
restart-required.
|
||||
- Every route has a policy class — `read`, `config_write` or `runtime_action` —
|
||||
and in file mode the `config_write` routes are refused. See
|
||||
[Configuration authority](#configuration-authority).
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -113,70 +116,140 @@ to the capacity is admitted and the long-run rate holds.
|
||||
holds at most 32 concurrent streams in total; when all slots are taken, the
|
||||
answer is a 503.
|
||||
|
||||
## Configuration authority
|
||||
|
||||
Which authority is live decides whether the API may write configuration. Under
|
||||
`nxdns run` the database is authority and every route behaves as it always has.
|
||||
Under `nxdns run --config FILE` the file is authority, and the routes that would
|
||||
edit configuration are refused: the file is the only place configuration
|
||||
changes, and a restart is what applies them.
|
||||
|
||||
### The refusal
|
||||
|
||||
A `config write` route in file mode answers **403** with the ordinary error
|
||||
envelope:
|
||||
|
||||
```json
|
||||
{"error":"configuration is managed by /etc/nxdns/config.zon; edit the file and restart"}
|
||||
```
|
||||
|
||||
There is no `code` field and no richer body. 403 is used for nothing else in
|
||||
this API, so the status alone is the machine-readable part, and a client that
|
||||
wants to know the mode in advance reads it from `GET /api/settings` rather than
|
||||
probing for errors.
|
||||
|
||||
**401 comes first.** The router matches the path, spends a rate-limit token,
|
||||
checks the session, and only then checks the policy. So an unauthenticated
|
||||
request to a `config write` route in file mode is a 401, not a 403 — answering
|
||||
403 first would tell an anonymous caller which routes exist.
|
||||
|
||||
`runtime action` and `read` routes are unaffected in both modes. Pausing
|
||||
blocking, refreshing blocklists, reloading certificates and logging in are
|
||||
operations on a running process, not statements about configuration, so a
|
||||
file-mode box still does all of them.
|
||||
|
||||
`DELETE /api/clients/{id}` is the one route whose answer depends on the row.
|
||||
Deleting a client the file does not declare is a runtime action and succeeds:
|
||||
without it, a mis-identified or departed device would be immortal in file mode,
|
||||
since the file can add addresses but never remove one it has never named.
|
||||
Deleting a client the file *does* declare contradicts the file, and answers the
|
||||
same 403.
|
||||
|
||||
### Discovering the authority
|
||||
|
||||
`GET /api/settings` carries an `authority` object:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `mode` | `"database"` or `"managed_file"`. |
|
||||
| `path` | The managed file's path, or `null` in database mode. |
|
||||
| `reconciled_at` | Unix seconds when this process loaded the file, or `null` in database mode. |
|
||||
|
||||
All three keys are always present; the two nullable ones carry `null` rather
|
||||
than being omitted, so a client can read `authority.mode` without probing.
|
||||
|
||||
```json
|
||||
{"mode": "database", "path": null, "reconciled_at": null}
|
||||
{"mode": "managed_file", "path": "/etc/nxdns/config.zon", "reconciled_at": 1786474016}
|
||||
```
|
||||
|
||||
The route requires a session, which is why the filesystem path is here rather
|
||||
than on the open `/api/version` and `/api/health`.
|
||||
|
||||
`reconciled_at` answers exactly one question: **when did this process last read
|
||||
the file?** Compare it against the file's mtime to spot a restart that has not
|
||||
happened yet. It is a hint and not a verdict, in both directions — a clock that
|
||||
stepped, or a copy that preserved mtimes (`git checkout`, `rsync -a`), can make
|
||||
a newer file look older, and the database can change without either timestamp
|
||||
moving. It does not tell you whether the file and the running configuration
|
||||
agree; answering that would take content hashing, which nxdns deliberately does
|
||||
not do.
|
||||
|
||||
## Operations
|
||||
|
||||
Auth `open` means no session is 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.
|
||||
`exempt` never consults the limiter. Policy `config write` is the class refused
|
||||
in file mode; `read` and `runtime action` are always served.
|
||||
|
||||
| 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 |
|
||||
| Method | Path | Auth | Rate limit | Policy | Purpose |
|
||||
|---|---|---|---|---|---|
|
||||
| GET | `/metrics` | open | exempt | read | Prometheus metrics |
|
||||
| GET | `/api/health` | open | exempt | read | Health rollup |
|
||||
| GET | `/api/version` | open | counted | read | Build and uptime |
|
||||
| GET | `/api/openapi.yaml` | open | counted | read | This API's OpenAPI document |
|
||||
| POST | `/api/auth/login` | open | counted | runtime action | Log in |
|
||||
| POST | `/api/auth/logout` | session | counted | runtime action | Log out |
|
||||
| GET | `/api/queries` | session | counted | read | Query log page |
|
||||
| GET | `/api/queries/live` | session | exempt | read | Live query stream (server-sent events) |
|
||||
| GET | `/api/stats` | session | counted | read | Totals for a period |
|
||||
| GET | `/api/stats/timeseries` | session | counted | read | Bucketed counts for a period |
|
||||
| GET | `/api/lookup` | session | counted | read | Explain a domain |
|
||||
| GET | `/api/upstream/health` | session | counted | read | Upstream pool health |
|
||||
| GET | `/api/groups` | session | counted | read | List groups |
|
||||
| POST | `/api/groups` | session | counted | config write | Create a group |
|
||||
| GET | `/api/groups/{id}` | session | counted | read | Read a group |
|
||||
| PUT | `/api/groups/{id}` | session | counted | config write | Update a group |
|
||||
| DELETE | `/api/groups/{id}` | session | counted | config write | Delete a group |
|
||||
| GET | `/api/groups/{id}/sources` | session | counted | read | Blocklist sources assigned to a group |
|
||||
| PUT | `/api/groups/{id}/sources` | session | counted | config write | Replace the assignment |
|
||||
| GET | `/api/blocklists` | session | counted | read | List blocklist sources |
|
||||
| POST | `/api/blocklists` | session | counted | config write | Add a blocklist source |
|
||||
| POST | `/api/blocklists/update` | session | counted | runtime action | Refresh every enabled source now |
|
||||
| GET | `/api/blocklists/{id}` | session | counted | read | Read a blocklist source |
|
||||
| PUT | `/api/blocklists/{id}` | session | counted | config write | Update a blocklist source |
|
||||
| DELETE | `/api/blocklists/{id}` | session | counted | config write | Delete a blocklist source |
|
||||
| GET | `/api/rules` | session | counted | read | List rules |
|
||||
| POST | `/api/rules` | session | counted | config write | Create a rule |
|
||||
| GET | `/api/rules/{id}` | session | counted | read | Read a rule |
|
||||
| PUT | `/api/rules/{id}` | session | counted | config write | Update a rule |
|
||||
| DELETE | `/api/rules/{id}` | session | counted | config write | Delete a rule |
|
||||
| GET | `/api/local-records` | session | counted | read | List local DNS records |
|
||||
| POST | `/api/local-records` | session | counted | config write | Create a local record |
|
||||
| GET | `/api/local-records/{id}` | session | counted | read | Read a local record |
|
||||
| PUT | `/api/local-records/{id}` | session | counted | config write | Update a local record |
|
||||
| DELETE | `/api/local-records/{id}` | session | counted | config write | Delete a local record |
|
||||
| GET | `/api/forward-zones` | session | counted | read | List forward zones |
|
||||
| POST | `/api/forward-zones` | session | counted | config write | Create a forward zone |
|
||||
| GET | `/api/forward-zones/{id}` | session | counted | read | Read a forward zone |
|
||||
| PUT | `/api/forward-zones/{id}` | session | counted | config write | Update a forward zone |
|
||||
| DELETE | `/api/forward-zones/{id}` | session | counted | config write | Delete a forward zone |
|
||||
| GET | `/api/clients` | session | counted | read | List clients |
|
||||
| GET | `/api/clients/{id}` | session | counted | read | Read a client |
|
||||
| PUT | `/api/clients/{id}` | session | counted | config write | Rename or regroup a client |
|
||||
| DELETE | `/api/clients/{id}` | session | counted | runtime action | Forget a client |
|
||||
| GET | `/api/client-prefixes` | session | counted | read | List client prefixes |
|
||||
| PUT | `/api/client-prefixes` | session | counted | config write | Replace the prefix table |
|
||||
| GET | `/api/upstreams` | session | counted | read | List upstream resolvers |
|
||||
| POST | `/api/upstreams` | session | counted | config write | Add an upstream |
|
||||
| GET | `/api/upstreams/{id}` | session | counted | read | Read an upstream |
|
||||
| PUT | `/api/upstreams/{id}` | session | counted | config write | Update an upstream |
|
||||
| DELETE | `/api/upstreams/{id}` | session | counted | config write | Delete an upstream |
|
||||
| GET | `/api/pause` | session | counted | read | Read the pause state |
|
||||
| POST | `/api/pause` | session | counted | runtime action | Pause or resume blocking |
|
||||
| GET | `/api/settings` | session | counted | read | Read the scalar settings |
|
||||
| PUT | `/api/settings` | session | counted | config write | Update settings |
|
||||
| POST | `/api/certs/reload` | session | counted | runtime action | Reload the TLS certificates from disk |
|
||||
|
||||
There is no `POST /api/clients`: client rows come from DNS activity or import,
|
||||
never from the API.
|
||||
@@ -194,6 +267,11 @@ write-only (accepted on a `PUT`, never returned, hashed before storage), and
|
||||
`web.password_hash` is neither readable nor directly writable, because a client
|
||||
that could install a hash could install one whose password it already knows.
|
||||
|
||||
In file mode `PUT /api/settings` is refused with the 403 above, password changes
|
||||
included. The password then lives where the rest of the configuration lives: set
|
||||
`web.password` in the file and restart. See
|
||||
[Password and hash](configuration.md#password-and-hash).
|
||||
|
||||
## Schemas
|
||||
|
||||
Request and response schemas for every operation live in the OpenAPI document:
|
||||
|
||||
+212
-48
@@ -9,9 +9,10 @@ of truth: `src/cli.zig`.
|
||||
|
||||
Every flag takes both spellings, `--flag value` and `--flag=value`. An attached
|
||||
value that is empty (`--config=`) is a missing value, not an empty path.
|
||||
`--force` is boolean and takes no value at all, so `--force=1` is not a spelling
|
||||
of any flag this program has. A flag is rejected by the subcommand that has no
|
||||
use for it: `--web-dev` outside `run` is an unknown flag, not a no-op.
|
||||
`--allow-delete` is boolean and takes no value at all, so `--allow-delete=1` is
|
||||
not a spelling of any flag this program has. A flag is rejected by the
|
||||
subcommand that has no use for it: `--web-dev` outside `run` is an unknown flag,
|
||||
not a no-op.
|
||||
|
||||
## `run`
|
||||
|
||||
@@ -20,12 +21,81 @@ Serves DNS until SIGINT or SIGTERM.
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir DIR` | Data directory (default `/var/lib/nxdns`). Created at mode 0700 if missing. |
|
||||
| `--config FILE` | Seed configuration file (default `/etc/nxdns/config.zon`). Read only when the database has never been configured. |
|
||||
| `--config FILE` | Make FILE the sole source of configuration and reconcile the database onto it at every start. No default: without this flag the database is the configuration and no file is read. |
|
||||
| `--web-dev DIR` | Serve the web interface from DIR instead of the embedded assets, with no cache headers. Development only. |
|
||||
|
||||
A seed file that is unparseable, oversized or invalid prints its diagnostics and
|
||||
exits 2 — the same code `check` and `import` give for the same file. See
|
||||
[exit codes](#exit-codes).
|
||||
### Which authority the invocation selects
|
||||
|
||||
The presence of `--config` picks the authority, and nothing else does. There is
|
||||
no default path, no probe of `/etc/nxdns`, and nothing recorded in the database:
|
||||
a configuration file sitting at `/etc/nxdns/config.zon` that no flag names
|
||||
changes nothing at all.
|
||||
|
||||
| Invocation | Authority | What a start does |
|
||||
| --- | --- | --- |
|
||||
| `nxdns run` | The database | Serves what `config.db` holds. Nothing reads a file. |
|
||||
| `nxdns run --config FILE` | FILE | Reads and validates FILE, reconciles the database onto it, then serves. |
|
||||
|
||||
The first log line after the migrations names the mode, so a journal says which
|
||||
authority was live:
|
||||
|
||||
```
|
||||
info(nxdns): authority: database
|
||||
info(nxdns): authority: file (/etc/nxdns/config.zon)
|
||||
```
|
||||
|
||||
In file mode the reconcile prints what it changed before that line — per-table
|
||||
inserted (`+`), updated (`~`) and deleted (`-`) counts, the settings keys whose
|
||||
values changed, and any change to whether the admin password is set:
|
||||
|
||||
```
|
||||
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +45 ~0 -0;
|
||||
settings keys changed: dns.bind_ipv4 dns.port web.bind web.port …
|
||||
web authentication is now enabled
|
||||
```
|
||||
|
||||
A start whose file matches the database writes nothing and says so:
|
||||
|
||||
```
|
||||
reconciled '/etc/nxdns/config.zon': no changes
|
||||
```
|
||||
|
||||
Blocklist state is not declarative and survives every reconcile: a source whose
|
||||
URL the file still names keeps its row id, its checksum, its counters and its
|
||||
compiled `<id>.list` and `<id>.wild`, so a restart in file mode downloads
|
||||
nothing. Editing a source's URL is a new identity — a new row, a new id, and a
|
||||
fresh download.
|
||||
|
||||
### Failing to start in file mode
|
||||
|
||||
File mode fails closed. A file that is missing, unreadable, unparseable,
|
||||
oversized or invalid stops the start; nxdns never falls back to the database,
|
||||
because a fallback turns a deploy typo into a silently stale configuration.
|
||||
|
||||
```
|
||||
FAIL /etc/nxdns/config.zon: no such file
|
||||
nxdns run failed: ManagedConfigUnreadable
|
||||
run `nxdns check` to see the configuration in full
|
||||
```
|
||||
|
||||
That is exit 2, and `check --config` on the same path agrees. Only path-class
|
||||
open failures map that way — the file is not there, or the process may not read
|
||||
it. An open that fails for a reason a retry could clear, such as
|
||||
file-descriptor exhaustion or an I/O error, is exit 1: the box is wrong, not the
|
||||
configuration. See [exit codes](#exit-codes).
|
||||
|
||||
The exit-2 agreement covers `run --config` and `check --config`, which read the
|
||||
managed file through one shared helper. It does **not** extend to `import`'s
|
||||
positional argument: a missing file there is `import failed: FileNotFound`, exit
|
||||
1. That is deliberate rather than an oversight — the managed file is a
|
||||
declarative input an operator deploys, so its absence is a fact about the
|
||||
configuration, while `import`'s argument is a path typed at a prompt, and a
|
||||
mistyped path is a failed command rather than a verdict on anything.
|
||||
|
||||
Run `nxdns check --config FILE` before restarting anything that deploys a file.
|
||||
It grades every declarative fault `run` would hit — read, parse, size,
|
||||
validation — through the same code, which is what makes it a usable precondition
|
||||
in an Ansible handler.
|
||||
|
||||
## `check`
|
||||
|
||||
@@ -52,7 +122,7 @@ first. What it checks, in order:
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir DIR` | Data directory to look for `config.db` in. |
|
||||
| `--config FILE` | Check this file instead of the database. |
|
||||
| `--config FILE` | Grade this file instead of the database. |
|
||||
|
||||
### Failures and warnings
|
||||
|
||||
@@ -71,23 +141,38 @@ The last line is a summary, and it never contradicts the lines above it:
|
||||
|
||||
### 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. It is opened
|
||||
immutable, so `check` writes nothing to it; see
|
||||
[what `check` does not do](#what-check-does-not-do).
|
||||
- Otherwise, if the default config file path exists: check it.
|
||||
- Otherwise: "nothing to check", exit 2.
|
||||
The flag decides, exactly as it does for `run`. There is no fallback and no
|
||||
probing of a default path:
|
||||
|
||||
The first line of output always names which source was checked. A file larger
|
||||
than 4 MiB fails with `larger than 4194304 bytes`; a ZON syntax error is
|
||||
- `--config FILE`: grade that file, and never open the database.
|
||||
- No `--config`: grade `<data-dir>/config.db`. It is opened immutable, so
|
||||
`check` writes nothing to it; see
|
||||
[what `check` does not do](#what-check-does-not-do).
|
||||
|
||||
So `nxdns check` and `nxdns check --config FILE` grade what the matching `run`
|
||||
invocation would serve. That is what makes `check` a pre-restart gate rather
|
||||
than an approximation of one.
|
||||
|
||||
A bare `check` on a box with no database says so and names both ways out of the
|
||||
state, exit 2:
|
||||
|
||||
```
|
||||
no config database at /var/lib/nxdns/config.db
|
||||
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
|
||||
```
|
||||
|
||||
The first line of output otherwise always names which source was checked. A file
|
||||
larger than 4 MiB fails with `larger than 4194304 bytes`; a ZON syntax error is
|
||||
reported with its line and column.
|
||||
|
||||
A named file that is missing or unreadable is a finding like any other, not an
|
||||
I/O failure that escapes the run: `FAIL <path>: no such file` or
|
||||
`FAIL <path>: not readable`, exit 2. That is the same code the implicit path
|
||||
gives when there is nothing to check, so naming the file does not change what a
|
||||
missing file costs.
|
||||
`FAIL <path>: not readable`, exit 2.
|
||||
|
||||
What `check --config` cannot see is the reconcile itself. It needs no database,
|
||||
so faults that only a write can produce — a disk that is full, a lock held by a
|
||||
restart that started first — are invisible to it. Those are runtime failures,
|
||||
exit 1, and they are not verdicts on the file.
|
||||
|
||||
### What `check` does not do
|
||||
|
||||
@@ -133,6 +218,15 @@ there. A directory that exists without a `config.db` is not that case: the
|
||||
database is opened with create semantics, so an empty `config.db` is created and
|
||||
migrated, and the export is of a default configuration.
|
||||
|
||||
The output is the same in both authority modes — nothing marks a file as
|
||||
exported from a file-mode box. That is what lets `export` be the adoption tool:
|
||||
the file you check is the file you deploy.
|
||||
|
||||
`web.password` is always written as `null` and `web.password_hash` carries the
|
||||
stored value, so an export re-imports without anyone knowing the password. A
|
||||
`null` password is not the same statement as an empty one: see
|
||||
[`web.password` and `web.password_hash`](configuration.md#password-and-hash).
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir DIR` | Data directory holding `config.db`. |
|
||||
@@ -142,25 +236,79 @@ See [back up and restore](../how-to/back-up-and-restore.md).
|
||||
|
||||
## `import FILE`
|
||||
|
||||
Validates FILE and replaces the whole configuration with it in one transaction.
|
||||
Prints every validation problem; a failed import leaves the database untouched.
|
||||
Refuses a database that already holds configuration unless `--force` is given
|
||||
(`DatabaseNotEmpty`, exit 2). The check measures what an operator set, not what
|
||||
the network did: client rows the DNS path materialised from traffic never
|
||||
trigger the refusal on their own. Creates the data directory at mode 0700 if it
|
||||
is missing.
|
||||
|
||||
Client history survives the replacement. An address the database already knew
|
||||
keeps its first-seen and last-seen even when FILE names it; only an address it
|
||||
has never seen takes the import's clock. A client FILE leaves out is removed,
|
||||
history included.
|
||||
Converges the database onto FILE in one transaction — the same reconcile a
|
||||
file-mode `run` performs, done once from the command line. Prints every
|
||||
validation problem; a failed import leaves the database untouched. Creates the
|
||||
data directory at mode 0700 if it is missing.
|
||||
|
||||
`FILE` is positional and may appear before or after the flags.
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir DIR` | Data directory holding `config.db` (created if missing). |
|
||||
| `--force` | Replace a database that already holds configuration. |
|
||||
| `--allow-delete` | Apply a file whose diff deletes rows. |
|
||||
|
||||
**`import` is a stop-first operation.** It rewrites configuration underneath a
|
||||
process that read it at startup, and a running server notices only some of it:
|
||||
filtering picks up the imported rows at the next reload, while upstreams,
|
||||
listeners and settings stay at their boot values until a restart.
|
||||
|
||||
### The delete gate
|
||||
|
||||
Rows the database holds and FILE does not name are deleted. That is the point of
|
||||
a declarative apply, and it is also how a mistaken `nxdns import ./wrong.zon`
|
||||
empties a configured server, so it takes a flag:
|
||||
|
||||
```
|
||||
FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it
|
||||
import failed: DestructiveImport
|
||||
```
|
||||
|
||||
That is exit 2, and the transaction rolls back. The message names every table
|
||||
with a non-zero delete count, so you can tell an intended pruning from a wrong
|
||||
file before applying anything.
|
||||
|
||||
An import that only adds rows, or only edits them, needs no flag. "Edit" here
|
||||
means a change to a row nxdns can still recognise as the same row. Each table
|
||||
has one column, or one tuple, that establishes identity:
|
||||
|
||||
| Table | Identity |
|
||||
| --- | --- |
|
||||
| `blocklist_sources`, `upstreams` | `url` |
|
||||
| `groups` | `name` |
|
||||
| `clients` | `ip` |
|
||||
| `client_prefixes` | `prefix` |
|
||||
| `forward_zones` | `zone` |
|
||||
| `local_records` | `(name, rtype, value)` |
|
||||
| `rules` | `(group, pattern, kind, action)` |
|
||||
|
||||
Change anything else on a row — a source's name, a group's `safe_search`, a
|
||||
client's group — and it is an edit, applied without a flag. Change the identity
|
||||
itself, such as renaming a group or correcting a typo in an upstream URL, and
|
||||
the engine sees a row that vanished and a row that appeared: that needs
|
||||
`--allow-delete`.
|
||||
|
||||
### What survives an import
|
||||
|
||||
Runtime state is not declarative and is preserved by identity, not by luck. A
|
||||
blocklist source whose URL is unchanged keeps its row id, its checksum, its
|
||||
counters and its compiled files, so an import costs no downloads. Clients the
|
||||
DNS path materialised from traffic are kept whole; naming one in the file
|
||||
promotes that row in place, keeping its first-seen and last-seen. Observed
|
||||
clients whose group the file no longer declares are moved to the `default`
|
||||
group rather than deleted with it, and none of that ever trips the delete gate.
|
||||
|
||||
### `import` against a file-mode box
|
||||
|
||||
It behaves like any other import. Nothing in the database records that a file
|
||||
governs it — authority lives in the invocation — so `import` neither detects nor
|
||||
refuses that case. The next restart's reconcile converges the database back to
|
||||
the file and its summary reports what it corrected. A source the import deleted
|
||||
comes back with a new row id, which means a fresh download of the whole list.
|
||||
|
||||
If a restart and an import race for the write lock, one of them simply wins: both
|
||||
take `BEGIN IMMEDIATE` under a 5-second busy timeout, so the outcome is an
|
||||
ordering, never a corrupted database.
|
||||
|
||||
## `version`
|
||||
|
||||
@@ -185,28 +333,44 @@ Code 2 means the same thing from every subcommand. `src/config/faults.zig`
|
||||
holds the one list of errors that mean "the configuration the operator supplied
|
||||
is wrong", and `run`, `check` and `import` all ask it, so a rejected file exits
|
||||
2 whichever command read it. The list is every error the validator raises, plus
|
||||
`ParseZon`, `ConfigTooLarge`, `NoUsableUpstreams` and `BadCertificate`. In
|
||||
practice that covers a seed file with a syntax error, one larger than 4 MiB, one
|
||||
with no `default` group (`MissingDefaultGroup`), one with no enabled upstream
|
||||
(`NoUpstreams`), a bad bind address, a bad rate limit, an unusable certificate,
|
||||
and `password` and `password_hash` set together.
|
||||
`ParseZon`, `ConfigTooLarge`, `NoUsableUpstreams`, `BadCertificate` and
|
||||
`ManagedConfigUnreadable`. In practice that covers a file with a syntax error,
|
||||
one larger than 4 MiB, one with no `default` group (`MissingDefaultGroup`), one
|
||||
with no enabled upstream (`NoUpstreams`), a bad bind address, a bad rate limit,
|
||||
an unusable certificate, `password` and `password_hash` set together, and a
|
||||
`--config` path that is absent or unreadable.
|
||||
|
||||
When `run` exits 2 it points at the diagnosis on stderr, whether the fault came
|
||||
from the seed file or from the database it loaded:
|
||||
The last of those is the one deliberate seam. A file nxdns cannot open is a
|
||||
configuration fault only when the *path* is the problem — the file is missing,
|
||||
permissions deny it, a path component is not a directory. Every other open
|
||||
failure, such as running out of file descriptors, is exit 1. The distinction
|
||||
earns its keep under the shipped systemd unit, which stops the service on exit 2
|
||||
rather than restarting it: a transient box fault graded as a configuration fault
|
||||
would take the resolver down until someone noticed.
|
||||
|
||||
When `run` exits 2 it points at the diagnosis on stderr, whichever authority the
|
||||
fault came from:
|
||||
|
||||
```
|
||||
run `nxdns check` to see the configuration in full
|
||||
```
|
||||
|
||||
`check` exits 2 for those faults and also when a probed upstream failed, when a
|
||||
named configuration file is missing or unreadable, when the database cannot be
|
||||
read or is not at this binary's schema version, and when there was nothing to
|
||||
check. Warnings never contribute.
|
||||
On a box with no configuration at all, `run` and `check` add the line that names
|
||||
both ways to get one:
|
||||
|
||||
`import` exits 2 for those faults and for `DatabaseNotEmpty`. That last one is
|
||||
deliberately not a configuration fault — it reports the state of the database
|
||||
rather than the content of a file — and `import` decides it for itself; the
|
||||
answer to it is `--force`, not an edit.
|
||||
```
|
||||
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
|
||||
```
|
||||
|
||||
`check` exits 2 for those faults and also when a probed upstream failed, when a
|
||||
named configuration file is missing or unreadable, and when the database cannot
|
||||
be read, is absent, or is not at this binary's schema version. Warnings never
|
||||
contribute.
|
||||
|
||||
`import` exits 2 for those faults and for `DestructiveImport`. That last one is
|
||||
deliberately not a configuration fault — it reports what applying the file would
|
||||
delete, rather than anything wrong with its content — and `import` decides it
|
||||
for itself; the answer to it is `--allow-delete`, not an edit.
|
||||
|
||||
`OutOfMemory` is exit 1 even when problems were recorded, because the report is
|
||||
then incomplete. Every other error is 1.
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 the defaults),
|
||||
`src/config/validate.zig` (the rules), `src/config/{bootstrap,import,export}.zig`
|
||||
`src/config/validate.zig` (the rules), `src/config/{loader,reconcile,import,export}.zig`
|
||||
(the lifecycle).
|
||||
|
||||
For how the file, the database and `export`/`import` relate to each other, see
|
||||
@@ -17,7 +17,7 @@ reference](cli.md).
|
||||
The file is ZON: a top-level anonymous struct whose fields are the sections and
|
||||
collections below. Enum values are ZON enum literals (`.level = .err`,
|
||||
`.response = .nxdomain`). Strings are double-quoted. The file may be at most
|
||||
4 MiB (`max_config_bytes` in `src/config/import.zig`); beyond that the error is
|
||||
4 MiB (`max_config_bytes` in `src/config/loader.zig`); beyond that the error is
|
||||
`ConfigTooLarge`. A syntax error is reported with its line and column.
|
||||
|
||||
Absent fields keep their defaults, both in the file and in the database. A
|
||||
@@ -38,7 +38,7 @@ Storage paths are process arguments, not configuration:
|
||||
| Argument | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `--data-dir DIR` | `/var/lib/nxdns` | Holds `config.db` and `querylog.db`; see [files and directories](files-and-directories.md). |
|
||||
| `--config FILE` | `/etc/nxdns/config.zon` | Names the seed file. |
|
||||
| `--config FILE` | — | On `run`, makes FILE the sole source of configuration; on `check`, grades FILE instead of the database. No default: without it the database is the configuration. |
|
||||
| `--web-dev DIR` | — | `run` only; serves the web interface from a directory instead of the embedded assets. |
|
||||
|
||||
## Scalar sections
|
||||
@@ -115,8 +115,8 @@ The web interface and REST API.
|
||||
| `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 of either family | web listener bind (`src/web/server.zig`) |
|
||||
| `web.port` | u16 | 8080 | port | 1–65535 (0 is refused) | web listener port |
|
||||
| `web.password` | string | `""` | — | must not be set together with `web.password_hash` | operator input only; hashed at import and discarded. Never a settings row — see [Password and hash](#password-and-hash) |
|
||||
| `web.password_hash` | string | `""` | — | — | argon2id PHC string verified at login (`src/web/auth.zig`); `""` disables authentication |
|
||||
| `web.password` | optional string | absent | — | must not be set together with `web.password_hash`; the empty string is refused | operator input only; hashed and discarded. Never a settings row — see [Password and hash](#password-and-hash) |
|
||||
| `web.password_hash` | optional string | absent | — | — | argon2id PHC string verified at login (`src/web/auth.zig`); `""` disables authentication, absent keeps the stored hash |
|
||||
| `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 |
|
||||
@@ -341,27 +341,59 @@ deadline.
|
||||
|
||||
## Password and hash
|
||||
|
||||
Exactly one of `web.password` and `web.password_hash` may be set; setting both
|
||||
is refused (`PasswordAndHashBothSet` — ambiguity in a security setting).
|
||||
Both fields are optional, and the difference between *absent* and *empty* is the
|
||||
whole design. Absent means "keep whatever is stored". Empty means "there is no
|
||||
password".
|
||||
|
||||
- `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. There is no `web.password` settings row,
|
||||
and `nxdns export` always writes `.password = ""`.
|
||||
| The file says | What happens to the stored hash |
|
||||
| --- | --- |
|
||||
| Neither field | Untouched. Authentication stays exactly as it was. |
|
||||
| `.password = "some-password"` | Verified against the stored hash; kept when it matches, replaced with a fresh hash when it does not. |
|
||||
| `.password = ""` | Refused, with a diagnostic naming the remedy. |
|
||||
| `.password_hash = "$argon2id$…"` | Written verbatim. |
|
||||
| `.password_hash = ""` | Cleared, which disables authentication. |
|
||||
| Both fields | Refused (`PasswordAndHashBothSet` — ambiguity in a security setting). |
|
||||
|
||||
Silence has to mean "keep", because the alternative is a trap. An operator who
|
||||
exports a configuration and trims the long PHC string out of it before
|
||||
committing the file to git means "leave the password alone", not "open the admin
|
||||
interface to the LAN". So disabling authentication takes the explicit empty
|
||||
string, and the empty *plaintext* — which would otherwise hash into a real hash
|
||||
that no login can ever satisfy — is refused outright:
|
||||
|
||||
```
|
||||
FAIL web.password: password is set to the empty string; omit the field to keep the stored password, or set password_hash = "" to disable authentication
|
||||
```
|
||||
|
||||
- `web.password` is operator input only. It is hashed with argon2id (OWASP
|
||||
parameters: t=2, m=19 MiB, p=1, PHC encoding) into `web.password_hash` and
|
||||
discarded. There is no `web.password` settings row, and `nxdns export` always
|
||||
writes `.password = null`.
|
||||
- `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. See [set up admin
|
||||
A plaintext password that has not changed is verified rather than re-hashed, so
|
||||
applying the same file twice leaves the same bytes in the database. That is what
|
||||
keeps the export/import round trip byte-stable with a password in the file. It
|
||||
costs a full argon2id computation either way — the verification is not a
|
||||
shortcut, and caching the plaintext to skip it would be a security bug.
|
||||
|
||||
Any change to whether a password is set is announced at startup, never left as a
|
||||
count:
|
||||
|
||||
```
|
||||
web authentication is now enabled
|
||||
web authentication is now disabled
|
||||
```
|
||||
|
||||
See [set up admin
|
||||
authentication](../how-to/set-up-admin-authentication.md).
|
||||
|
||||
## Validation errors
|
||||
|
||||
`nxdns check`, `nxdns import` and the `nxdns run` that seeds a database from the
|
||||
file all print one `FAIL path: message` line per problem, and report every
|
||||
`nxdns check`, `nxdns import` and a `nxdns run --config` that reads the file
|
||||
all print one `FAIL path: message` line per problem, and report every
|
||||
problem rather than the first. A finding that is legal but almost certainly
|
||||
unintended is prefixed `WARN` instead: it does not change the exit code, and it
|
||||
is printed by all three even when nothing failed, so an accepted configuration
|
||||
@@ -461,7 +493,7 @@ upstream. Everything else keeps its default.
|
||||
|
||||
.cache = .{ .size = 10000, .negative_ttl_max = 3600 },
|
||||
|
||||
// Web UI on 8080. The password is hashed at import and never stored;
|
||||
// Web UI on 8080. The password is hashed and never stored as plaintext;
|
||||
// leave .password_hash out when setting .password (they are exclusive).
|
||||
.web = .{
|
||||
.enabled = true,
|
||||
|
||||
@@ -10,11 +10,9 @@ snapshots), `src/platform/logging.zig` (the log file).
|
||||
Default `/var/lib/nxdns`, overridable with `--data-dir DIR`. `nxdns run` and
|
||||
`nxdns import` create it and its parents at mode 0700 when it is missing;
|
||||
`nxdns check` and `nxdns export` do not create it. `export` fails if it is not
|
||||
there. `check` opens it only on the branch that resolved to the database, so an
|
||||
absent data directory is not in itself a failure: an explicit `--config FILE`
|
||||
never looks at the directory, and without one `check` falls back to the default
|
||||
configuration file, or prints "nothing to check" and exits 2 when neither source
|
||||
exists.
|
||||
there. `check` opens it only when no `--config FILE` was given: with that flag it
|
||||
grades the file and never looks at the directory at all. Without it, an absent
|
||||
`config.db` is a failure naming the two ways to get one, exit 2.
|
||||
|
||||
`run`, `import` and `export` go through `DataDir.openConfigDb`, which opens
|
||||
`config.db` read/write, chmods it to 0600, enables WAL — creating
|
||||
@@ -39,7 +37,7 @@ older ones from the main file. That is the "uncheckpointed changes" failure in
|
||||
|
||||
| Path | What it is | Mode |
|
||||
| --- | --- | --- |
|
||||
| `config.db` | The configuration database — the single source of truth, including `web.password_hash`. | 0600 |
|
||||
| `config.db` | The configuration database, including `web.password_hash`. The source of truth in database mode; in file mode it is the runtime substrate the file is reconciled onto (see [the configuration file](#the-configuration-file)). | 0600 |
|
||||
| `config.db-wal`, `config.db-shm` | SQLite write-ahead log and shared-memory index for `config.db`. Created by `run`, `import` and `export` when WAL is enabled, inheriting the main file's permissions. `check` creates neither. | 0600 |
|
||||
| `querylog.db` | The query log: every domain every client asked for. Expendable — if it is missing or unusable it is recreated empty. | 0600 |
|
||||
| `querylog.db-wal`, `querylog.db-shm` | WAL sidecars for `querylog.db`. | 0600 |
|
||||
@@ -112,9 +110,20 @@ than being created world-readable.
|
||||
|
||||
## The configuration file
|
||||
|
||||
Default `/etc/nxdns/config.zon`, overridable with `--config FILE`. nxdns reads
|
||||
it and never writes it: it seeds an unconfigured database once and is ignored
|
||||
afterwards. nxdns does not create the file or its directory.
|
||||
There is no default path. `--config FILE` names the file, and without that flag
|
||||
no file is read at all — a `config.zon` sitting in `/etc/nxdns` that no
|
||||
invocation names is inert. `/etc/nxdns/config.zon` is a convention the packaging
|
||||
follows, not a location nxdns probes.
|
||||
|
||||
nxdns reads the file and never writes it, in either authority mode. It does not
|
||||
create the file or its directory either; the systemd unit's
|
||||
`ConfigurationDirectory=nxdns` creates `/etc/nxdns`, and the same unit's
|
||||
`ReadOnlyPaths=/etc/nxdns` denies the service write access to it, so the file
|
||||
cannot be modified by the process that reads it.
|
||||
|
||||
Under `run --config FILE` the file is the configuration and the database is the
|
||||
runtime substrate the server reads from: every start reconciles the one onto the
|
||||
other. So in that mode `config.db` is not the backup — the file is.
|
||||
|
||||
`nxdns export --out FILE` writes a ZON file at mode 0600 through a temporary
|
||||
file and a rename. That file carries `web.password_hash`, so treat exports as
|
||||
|
||||
+98
-36
@@ -9,8 +9,13 @@ delete the directory.
|
||||
Follow the steps in order. Each one says what it did.
|
||||
|
||||
Every command below was executed on x86_64 Linux with Zig 0.16.0, Node.js
|
||||
24.14.1, dig 9.20.26 and curl 8.21.0. The only thing substituted during that run
|
||||
was the tutorial directory.
|
||||
24.14.1, dig 9.20.26 and curl 8.21.0. Steps 4 to 11, 13 and 14 were re-run end
|
||||
to end for this revision, and the transcripts are that run's output with the
|
||||
tutorial directory substituted. Two things were not re-run: the browser page in
|
||||
step 12 — its endpoints were exercised, the page itself was not opened — and the
|
||||
two build commands in steps 1 and 2, which had already produced the binary under
|
||||
test. The ZON block at the end of step 14 was checked with `nxdns check
|
||||
--config` rather than started.
|
||||
|
||||
## What you need
|
||||
|
||||
@@ -30,7 +35,7 @@ cd web && npm ci && npm run build && cd ..
|
||||
```
|
||||
|
||||
This produces `web/dist`. Do not skip it. A plain `zig build` embeds
|
||||
`web/dist-placeholder`, a one-page status stub, and you would reach step 10 and
|
||||
`web/dist-placeholder`, a one-page status stub, and you would reach step 12 and
|
||||
find no admin interface there.
|
||||
|
||||
## 2. Build nxdns
|
||||
@@ -75,6 +80,11 @@ itself, so a configuration missing either one is rejected. Whichever command
|
||||
reads the file says the same thing and stops the same way: `run`, `nxdns check`
|
||||
and `nxdns import` all print the problem and exit 2.
|
||||
|
||||
This tutorial loads the file into the database once and then runs nxdns against
|
||||
the database, which is what the packaged systemd unit does. There is a second
|
||||
way to run nxdns, where the file itself stays the configuration; step 14 shows
|
||||
what changes.
|
||||
|
||||
## 4. Check the configuration before starting
|
||||
|
||||
```sh
|
||||
@@ -92,33 +102,44 @@ answers. It exits 2 when it found something that has to be fixed and 0
|
||||
otherwise. It writes nothing and starts no listener, so you can run it as often
|
||||
as you like.
|
||||
|
||||
## 5. Start the server
|
||||
|
||||
In your first terminal:
|
||||
## 5. Load it into the database
|
||||
|
||||
```sh
|
||||
zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data --config ~/nxdns-tutorial/config.zon
|
||||
zig-out/bin/nxdns import ~/nxdns-tutorial/config.zon --data-dir ~/nxdns-tutorial/data
|
||||
```
|
||||
|
||||
```
|
||||
info(migrations): config.db migrated from schema version 0 to 2
|
||||
info(config_bootstrap): seeded the database from '/home/you/nxdns-tutorial/config.zon'
|
||||
imported /home/you/nxdns-tutorial/config.zon
|
||||
```
|
||||
|
||||
The data directory did not exist; nxdns created it at mode 0700 along with
|
||||
`config.db`. From here the database holds the configuration, and you will change
|
||||
it through the API rather than by editing the file again.
|
||||
|
||||
## 6. Start the server
|
||||
|
||||
In your first terminal:
|
||||
|
||||
```sh
|
||||
zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data
|
||||
```
|
||||
|
||||
```
|
||||
info(querylog_schema): created querylog database '/home/you/nxdns-tutorial/data/querylog.db'
|
||||
info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 199 bytes
|
||||
info(nxdns): authority: database
|
||||
info(nxdns): nxdns <version> serving on udp [::1]:15353 udp 127.0.0.1:15353 tcp [::1]:15353 tcp 127.0.0.1:15353; 1 upstream(s); blocklist generation 1
|
||||
info(web_server): web interface listening on 127.0.0.1:8080
|
||||
```
|
||||
|
||||
The data directory did not exist; nxdns created it at mode 0700 along with
|
||||
`config.db` and `querylog.db`. The line that matters is `seeded the database
|
||||
from`: the configuration file was read into the database this once. From now on
|
||||
the database is the truth and the file is ignored on every later start, which
|
||||
you will see for yourself in step 11. Configuration changes go through the API,
|
||||
the web interface, or `nxdns import`.
|
||||
No `--config` here, and that is the point: `authority: database` says the
|
||||
database is the configuration and no file was opened at all. The file you wrote
|
||||
in step 3 has done its job.
|
||||
|
||||
Leave this terminal running and switch to the second one.
|
||||
|
||||
## 6. Resolve a name
|
||||
## 7. Resolve a name
|
||||
|
||||
```sh
|
||||
dig @127.0.0.1 -p 15353 example.com A +noall +answer
|
||||
@@ -131,10 +152,10 @@ example.com. 90 IN A 104.20.23.154
|
||||
|
||||
nxdns had no answer cached, so it forwarded the query to
|
||||
`https://cloudflare-dns.com/dns-query` over HTTPS and returned what came back.
|
||||
You now have a working resolver. It blocks nothing yet: the log line in step 5
|
||||
You now have a working resolver. It blocks nothing yet: the log line in step 6
|
||||
said `0 of 0 sources loaded`.
|
||||
|
||||
## 7. Add a blocklist source
|
||||
## 8. Add a blocklist source
|
||||
|
||||
```sh
|
||||
curl -s -X POST http://127.0.0.1:8080/api/blocklists \
|
||||
@@ -152,7 +173,7 @@ That is fine for a localhost tutorial and wrong for anything else; see
|
||||
|
||||
Note the `"id":1` in the response. You need it in the next step.
|
||||
|
||||
## 8. Attach the source to the `default` group
|
||||
## 9. Attach the source to the `default` group
|
||||
|
||||
A blocklist source belongs to the installation. Which groups use it is a
|
||||
separate decision, which is what lets one group get a strict list and another
|
||||
@@ -190,7 +211,7 @@ curl -s -X PUT http://127.0.0.1:8080/api/groups/1/sources \
|
||||
{"source_ids":[1]}
|
||||
```
|
||||
|
||||
## 9. Download the list
|
||||
## 10. Download the list
|
||||
|
||||
Adding a source registers it; it does not fetch it. Ask for a refresh:
|
||||
|
||||
@@ -199,25 +220,25 @@ curl -s -X POST http://127.0.0.1:8080/api/blocklists/update
|
||||
```
|
||||
|
||||
```json
|
||||
{"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1785683777,"last_success":1785683777,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":99277,"wildcards":0,"skipped_regex":0}]}
|
||||
{"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1786473715,"last_success":1786473715,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":99559,"wildcards":0,"skipped_regex":0}]}
|
||||
```
|
||||
|
||||
The download is about 3 MB and takes a few seconds. Watch the first terminal
|
||||
until this appears:
|
||||
|
||||
```
|
||||
info(blocklist_manager): blocklist snapshot generation 5: 1 of 1 sources loaded, 3088703 bytes
|
||||
info(blocklist_manager): blocklist snapshot generation 6: 1 of 1 sources loaded, 3096006 bytes
|
||||
```
|
||||
|
||||
`1 of 1 sources loaded` is the line to wait for. nxdns builds each blocklist
|
||||
snapshot in full and swaps it in atomically, so queries keep being answered from
|
||||
the previous snapshot the whole time the new one is being built. After this, the
|
||||
domain count in the JSON above — 99277 on the day this was run — is live.
|
||||
domain count in the JSON above — 99559 on the day this was run — is live.
|
||||
|
||||
From here on, the list is on disk under `~/nxdns-tutorial/data/blocklists`.
|
||||
Restarting nxdns does not re-download it.
|
||||
|
||||
## 10. Watch a domain get blocked
|
||||
## 11. Watch a domain get blocked
|
||||
|
||||
```sh
|
||||
dig @127.0.0.1 -p 15353 doubleclick.net A +noall +answer
|
||||
@@ -249,7 +270,7 @@ yourself can, with a wildcard pattern such as `*.doubleclick.net`. So when you
|
||||
pick a domain to test, pick one that is literally in the file.
|
||||
`www.google-analytics.com` is another that is.
|
||||
|
||||
## 11. Open the web interface
|
||||
## 12. Open the web interface
|
||||
|
||||
Visit <http://127.0.0.1:8080> in a browser. This is the single-page application
|
||||
you built in step 1, served out of the binary. The dashboard shows query and
|
||||
@@ -257,7 +278,7 @@ block counts, and the Blocklists page shows the source you added with its
|
||||
domain count. (The endpoints behind those two pages were checked while writing
|
||||
this; the browser page itself was not opened on the verification host.)
|
||||
|
||||
## 12. Stop it
|
||||
## 13. Stop it
|
||||
|
||||
In the first terminal, press Ctrl-C.
|
||||
|
||||
@@ -267,21 +288,61 @@ info(nxdns): shutting down
|
||||
|
||||
nxdns catches SIGINT and SIGTERM, stops serving and exits 0.
|
||||
|
||||
Start it again with the same command as in step 5 and read the first log line:
|
||||
Start it again with the same command as in step 6 and read the first log lines:
|
||||
|
||||
```
|
||||
info(config_bootstrap): configuration file ignored; the database is already configured
|
||||
info(blocklist_manager): blocklist snapshot generation 1: 1 of 1 sources loaded, 3088703 bytes
|
||||
info(blocklist_manager): blocklist snapshot generation 1: 1 of 1 sources loaded, 3096006 bytes
|
||||
info(nxdns): authority: database
|
||||
```
|
||||
|
||||
The configuration file was not even opened, and the blocklist came off disk
|
||||
rather than the network. The blocklist source, the group it is attached to, and
|
||||
the query log all survived the restart because they live in
|
||||
`~/nxdns-tutorial/data`.
|
||||
The blocklist came off disk rather than the network. The source you added, the
|
||||
group it is attached to, and the query log all survived the restart because they
|
||||
live in `~/nxdns-tutorial/data`, which is what `authority: database` means in
|
||||
practice.
|
||||
|
||||
Press Ctrl-C again to stop this second process. Nothing is listening on 15353 or
|
||||
8080 now, and nothing of nxdns is running.
|
||||
|
||||
## 14. See what the other mode does
|
||||
|
||||
You have been running in database mode. The alternative is to hand the same file
|
||||
back as the configuration, which is what `--config` means:
|
||||
|
||||
```sh
|
||||
zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data --config ~/nxdns-tutorial/config.zon
|
||||
```
|
||||
|
||||
```
|
||||
reconciled '/home/you/nxdns-tutorial/config.zon': sources +0 ~0 -1; group_sources +0 ~0 -1;
|
||||
info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 199 bytes
|
||||
info(nxdns): authority: file (/home/you/nxdns-tutorial/config.zon)
|
||||
```
|
||||
|
||||
**Read that first line.** The blocklist source is gone. That is not a bug — it is
|
||||
the whole contract. The file you wrote in step 3 never mentioned a blocklist
|
||||
source, and in file mode the file is the complete statement of what the
|
||||
configuration is, so anything the database holds that the file does not name is
|
||||
removed at every start. The reconcile said so in one line before doing it.
|
||||
|
||||
The compiled list is still on disk and the query log is untouched; what changed
|
||||
is the configuration, and it now matches the file exactly.
|
||||
|
||||
Neither mode is the "advanced" one. Database mode suits a box someone
|
||||
administers through the web interface. File mode suits a file kept in git and
|
||||
deployed by a tool, where the deployed file being what is running matters more
|
||||
than clicking. To have kept the blocklist here, you would put it in the file:
|
||||
|
||||
```zon
|
||||
.blocklist_sources = .{
|
||||
.{ .url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", .name = "StevenBlack hosts" },
|
||||
},
|
||||
.group_sources = .{
|
||||
.{ .group = "default", .source_url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts" },
|
||||
},
|
||||
```
|
||||
|
||||
Ctrl-C to stop it.
|
||||
|
||||
## What you have now
|
||||
|
||||
A resolver that answers real queries, a real blocklist of about 99000 domains
|
||||
@@ -292,9 +353,10 @@ directory you can delete:
|
||||
rm -rf ~/nxdns-tutorial
|
||||
```
|
||||
|
||||
You also saw the two things that surprise people most: the configuration file
|
||||
seeds the database once and is ignored afterwards, and a blocklist source does
|
||||
nothing until a group uses it.
|
||||
You also saw the three things that surprise people most: a blocklist source
|
||||
does nothing until a group uses it, which authority a start runs under is
|
||||
printed rather than guessed, and in file mode anything the file does not name is
|
||||
removed at the next start.
|
||||
|
||||
## Where to go next
|
||||
|
||||
@@ -305,4 +367,4 @@ nothing until a group uses it.
|
||||
- [reference/configuration.md](../reference/configuration.md) — every field you
|
||||
did not set.
|
||||
- [explanation/configuration-model.md](../explanation/configuration-model.md) —
|
||||
why the file seeds and the database rules.
|
||||
why there are two authority modes and what each one is for.
|
||||
|
||||
+110
-15
@@ -580,12 +580,87 @@ was reproduced before it was fixed.
|
||||
would have turned a licence check into an unpinned fetch. Reproduced: it
|
||||
fetched `vite@8.2.0` over the pinned `8.1.5`.
|
||||
|
||||
23. **`actions/checkout` destroys the annotated tag object.** Found by the first
|
||||
live dry run, not by review: on a tag ref, checkout fetches the *commit* SHA
|
||||
into `refs/tags/<tag>`, so the signed tag reads as lightweight and the guard
|
||||
refuses it as unannotated. Both jobs that read the tag object — signature
|
||||
verification in the guard, the tagger date in the publish job — now force-
|
||||
refetch `refs/tags/$TAG` from origin first. The same run also proved the
|
||||
fail-closed secret guard for real: the first dry-run attempt ran with no
|
||||
secrets configured (they were on the wrong repository) and stopped in the
|
||||
guard with nothing built or pushed.
|
||||
|
||||
24. **Publication orchestration moved out of workflow shell into
|
||||
`tools/release.zig`.** Ruling 5 already moved the packaging asserts out of
|
||||
CI shell for one reason — "checks that only exist inside a workflow file are
|
||||
the brittleness this exists to remove" — and the release job was the larger
|
||||
half of the same problem, left in place. Three live failures came out of it,
|
||||
and each was found by executing the workflow, which is the most expensive
|
||||
place to find anything: `actions/checkout` replacing the annotated tag object
|
||||
(deviation 23), the refetch that fixed it having no credentials because
|
||||
`persist-credentials` is off, and the multiline armored subkey escaping the
|
||||
runner's log masker, which masks per line.
|
||||
|
||||
Twelve subcommands, one per step group: `guard-tag`, `guard-ancestry`,
|
||||
`guard-releases`, `resolve`, `changelog`, `image`,
|
||||
`verify-image-binaries`, `sign`, `draft`, `latest`, `publish`, `scrub`. Every
|
||||
behaviour recorded in deviations 10 to 15 and 23 is carried over unchanged —
|
||||
probe-adopt, the VALIDSIG last field, the subkey-only import and signing
|
||||
probe, the array-shape guard on the releases payload, the `:latest` label
|
||||
read, the publish re-read, the per-home `gpgconf --kill`, the tag refetch.
|
||||
What is new is that the semver ordering, VALIDSIG field selection, challenge
|
||||
parsing, changelog extraction, checksum-line parsing, colon-format parsing
|
||||
and payload-shape guard are 25 unit tests in `zig build test` rather than
|
||||
shell that only ever runs on a tag push. `release.yml` keeps the triggers,
|
||||
the concurrency group, the job graph, the SHA pins, the two pinned
|
||||
fingerprints and the fail-closed secret presence check — which stays as
|
||||
shell, deliberately, so that it runs before the tool is even compiled.
|
||||
|
||||
The same reasoning applies to the `jq` pipeline of the bundled-package gate,
|
||||
which moved to `web/scripts/bundledPackages.mjs` with its own vitest
|
||||
coverage and an `npm run assert-bundled` entry point.
|
||||
|
||||
**Secret contract change:** `RELEASE_GPG_SUBKEY` keeps its name but now
|
||||
holds `base64 -w0` of the armored
|
||||
`--export-secret-subkeys` output rather than the armored text. Manual
|
||||
prerequisite 1 and 3 change accordingly. The tool decodes it in memory and
|
||||
writes it to a mode-600 file inside the temporary `GNUPGHOME`. A single-line
|
||||
secret is one the masker can actually mask.
|
||||
|
||||
25. **The first signing subkey was leaked into a job log and rotated.** Dry-run
|
||||
attempt 3 failed inside the credential-less refetch, and the runner printed
|
||||
the failing step's env block; the multiline armored `RELEASE_GPG_SUBKEY`
|
||||
escaped the per-line masker while the single-line passphrase was masked.
|
||||
Exposure: the passphrase-protected secret subkey only — the passphrase and
|
||||
the primary key were never on the runner. Response: both runs that ever saw
|
||||
the secret were deleted (verified 404 via the API and absent from
|
||||
`actions_log` on disk), subkey `B281CECC…` was revoked with the primary,
|
||||
and its replacement `019D00DF…` is the pinned `RELEASE_SIGNING_FPR`. The
|
||||
base64 contract in deviation 24 is the preventive half of this record.
|
||||
|
||||
26. **The image is named by the public registry host, never the server URL.**
|
||||
Attempt 4 reached the registry and failed at `docker login gitea:3000`:
|
||||
inside the cluster `GITHUB_SERVER_URL` is `http://gitea:3000`, docker
|
||||
refuses plain-http registries, and an image named `gitea:3000/…` would be
|
||||
unpullable from anywhere that matters — a wrong name that would have been
|
||||
written into the released `IMAGE-DIGEST.txt`. `release.yml` now pins
|
||||
`REGISTRY_HOST: git.mial.net`; the tool uses it for docker and image
|
||||
naming, and keeps the internal URL for the manifest probe (same registry,
|
||||
no TLS dependency in the tool). The old shell had the identical latent bug;
|
||||
no run ever reached it.
|
||||
|
||||
### Not verified, and why
|
||||
|
||||
- **No workflow has ever executed.** `release.yml` and `gates.yml` were validated
|
||||
by YAML parse and `bash -n`, plus two steps lifted out and run directly: the
|
||||
registry probe against a fake registry (five response shapes) and the whole
|
||||
bundled-package check against the real `web/` build, proven able to fail.
|
||||
- **The workflows' validation history.** Before any live run, `release.yml` and
|
||||
`gates.yml` were validated by YAML parse and `bash -n`, plus two steps lifted
|
||||
out and run directly: the registry probe against a fake registry (five
|
||||
response shapes) and the bundled-package check against the real `web/` build,
|
||||
proven able to fail. The live dry run then superseded this: attempt 5
|
||||
published `v0.0.0` end to end — guard, gates, image push to both platforms,
|
||||
binary-identity assertion, signing, draft, `:latest`, publication — and the
|
||||
assets verified from a clean directory (checksums OK, signature good under
|
||||
the rotated subkey). The throwaway release, tag and registry versions were
|
||||
deleted afterwards.
|
||||
Everything else that talks to the registry or the Gitea API — `buildx build
|
||||
--push`, `imagetools`, draft creation, asset upload, publication, the
|
||||
adopt-an-existing-tag path — is unexercised.
|
||||
@@ -620,24 +695,44 @@ was reproduced before it was fixed.
|
||||
- [x] Two runs of `zig build dist` on the same commit produce byte-identical
|
||||
tarballs **in the same directory**. (Cross-directory reproducibility is
|
||||
ruling 12 and is not claimed here.)
|
||||
- [ ] The image builds for both platforms with no qemu, carries `/LICENSE` and
|
||||
- [x] The image builds for both platforms with no qemu, carries `/LICENSE` and
|
||||
`/THIRD-PARTY-NOTICES` and the OCI labels, and its binaries are
|
||||
byte-identical to the tarball binaries. Verified for the native amd64
|
||||
image only; the arm64 half needs a runner with buildx.
|
||||
- [ ] `gates.yml` runs from both `ci.yml` and `release.yml`; `ci.yml` triggers
|
||||
on `master`; `origin/main` is gone. The first two are in the files; no
|
||||
workflow has run and `origin/main` still exists (manual prerequisite).
|
||||
byte-identical to the tarball binaries. The v0.0.1 run built and pushed
|
||||
both platforms on the runner; the published index lists exactly
|
||||
`linux/amd64 linux/arm64`, and `release verify-image-binaries` compared
|
||||
both binaries against the tarballs before publication.
|
||||
- [x] `gates.yml` runs from both `ci.yml` and `release.yml`; `ci.yml` triggers
|
||||
on `master`; `origin/main` is gone. Proven live: pushes to `master` run
|
||||
the gates through `ci.yml`, and release runs 484-493 ran them through
|
||||
`release.yml`.
|
||||
- [x] `THIRD-PARTY-NOTICES` covers musl, the Zig runtime, SQLite, Mbed TLS with
|
||||
its Apache-2.0 selection line and full text, Everest, p256-m and the web
|
||||
runtime closure. The dependency drift guard was proven able to fail:
|
||||
removing an inventory entry, staling a dependency version, staling the Zig
|
||||
version, changing the base image digest, editing a pinned licence text and
|
||||
dropping a package from the recorded bundle each produce a named failure.
|
||||
- [ ] A dry run of `release.yml` completes with publication disabled.
|
||||
- [ ] `v0.0.1` is published: five assets, a verifying signature, and an image at
|
||||
`git.mial.net/mokhtar/nxdns:0.0.1` and `:latest`.
|
||||
- [ ] `docs/how-to/verify-a-release.md` was followed end to end against the
|
||||
published release, from a clean directory, on this host.
|
||||
- [x] A dry run of `release.yml` completes with publication disabled. Done with
|
||||
a disposable published tag instead: publication cannot be disabled without
|
||||
forking the flow it is supposed to prove, so `v0.0.0` ran the real path
|
||||
end to end — five assets, verifying checksums and signature, a
|
||||
multi-architecture image — and was then deleted (release, git tag, both
|
||||
registry versions). Five attempts; the failures and their fixes are
|
||||
deviations 23-26.
|
||||
- [x] `v0.0.1` is published: five assets, a verifying signature, and an image at
|
||||
`git.mial.net/mokhtar/nxdns:0.0.1` and `:latest`. Run 493, all jobs green
|
||||
on the first attempt after the dry-run fixes.
|
||||
- [x] `docs/how-to/verify-a-release.md` was followed end to end against the
|
||||
published release, from a clean directory, on this host, with a clean
|
||||
`GNUPGHOME` holding only the key fetched from keys.openpgp.org. Every
|
||||
command on the page passed: the `releases/latest` redirect printed
|
||||
`0.0.1`, both tarball downloads and the `latest` alias worked (and
|
||||
GitHub's spelling answered 404 as documented), the signature verified
|
||||
with matching primary and subkey fingerprints, `sha256sum -c` said OK for
|
||||
all three files, the tarball layout and modes matched, `nxdns version`
|
||||
printed the tag's commit, the tag digest equalled `IMAGE-DIGEST.txt`, the
|
||||
platform list was exactly `linux/amd64 linux/arm64`, and the binary
|
||||
copied out of the pulled-by-digest image hashed identical to the tarball
|
||||
binary.
|
||||
- [x] No `zig build cross` or source-only-distribution text remains on any
|
||||
**active** surface: `build.zig`, the workflows, `deploy/`, `README.md` and
|
||||
`docs/`. Historical milestone specs and `TECH_DEBT.md` keep their text —
|
||||
|
||||
@@ -0,0 +1,921 @@
|
||||
# Milestone 20: declarative configuration for IaC
|
||||
|
||||
Goal: a config file an operator can keep in git and deploy with Ansible, where
|
||||
the file is the sole declarative source of truth, converged at every boot —
|
||||
without re-downloading every blocklist on every boot, and without the UI
|
||||
silently diverging from the file. Two authority modes, selected by the
|
||||
presence of one flag: bare `run` serves the DB; `run --config=<path>` makes
|
||||
the file authority.
|
||||
|
||||
Design finalized 2026-08-09 after three adversarial rounds (red team ops 1-16
|
||||
and debt 1-14; Codex cross-validation F1-F12; a Codex round on the premise
|
||||
revision). All findings are folded in below or declined with written reasons;
|
||||
all file:line anchors were re-verified at the pre-implementation HEAD
|
||||
(7039a9f). A premise revision replaced the earlier `--config-source` mode
|
||||
enum with presence-of-`--config` and deleted the persisted authority marker;
|
||||
rulings 1, 6 and 8 record the reasons — do not reintroduce either.
|
||||
|
||||
## Implementation contract (read first)
|
||||
|
||||
- Read `AGENTS.md`, then this spec whole, before session work starts.
|
||||
- Session order: R1 → R2 sequential; R3 and R4 parallel to both (Sessions,
|
||||
below). File ownership is write-exclusivity; the interfaces between
|
||||
sessions (`reconcile.Summary`, `WebState.authority` + `reconciled_at`,
|
||||
`RouteInfo.policy`) are fixed in this spec and are not renegotiable
|
||||
mid-build.
|
||||
- Gates: `zig build test` and `zig build test -Dintegration` with 0 failed,
|
||||
plus the drift-guard regenerations the Tests section names. Skip counts are
|
||||
reported with their reasons (plain-suite skips are integration-gated; the 4
|
||||
integration skips are the live-network TLS tests excluded by milestone-1
|
||||
design). One `-Dlive` run covers the real download path (Tests).
|
||||
- No `std.log.err` in new code. No secrets in logs — url redaction goes
|
||||
through `src/safe_url.zig` as everywhere else.
|
||||
- Every fix or behaviour claim lands with a test the author watched fail
|
||||
against the reverted implementation (milestone-13 ruling F-f applies).
|
||||
- Doc pages touched by R4 follow milestone-13 ruling 3: every command block
|
||||
in `tutorial/` and `how-to/` is executed on this host by the session that
|
||||
writes it, or marked in-page as unverified with the reason.
|
||||
- The final commit is GPG-signed by the user (`git commit -S`, lowercase,
|
||||
single line); stage the work and hand the command over.
|
||||
|
||||
## Rulings (binding)
|
||||
|
||||
### 1. Authority is the invocation: `--config` present means the file governs
|
||||
|
||||
`nxdns run` — the DB is authority, today's appliance behaviour.
|
||||
`nxdns run --config=/etc/nxdns/config.zon` — the file is authority. The
|
||||
flag's presence selects the mode; there is no mode enum and no default path.
|
||||
`check` keeps its existing surface under the same rule: bare `check` grades
|
||||
the DB, `check --config <file>` grades the file. The DB-exists-wins heuristic
|
||||
in `checkImpl` (cli.zig:559-588) and `CheckArgs.config_explicit` are deleted.
|
||||
|
||||
Two principles, separated deliberately, because conflating them is what
|
||||
produced the rejected `--config-source` enum:
|
||||
|
||||
- **Authority must be explicit in the invocation.** An operator reads
|
||||
`ExecStart` and knows which authority is live. Probing `/etc/nxdns` for a
|
||||
file and switching behaviour on its existence is ambient magic — the same
|
||||
class of heuristic that made seed-once bootstrap a source of doc lies (the
|
||||
compose comment, the first-run tutorial) — and stays banned: a file on disk
|
||||
that no flag names changes nothing.
|
||||
- **A mode enum is the wrong shape for a two-state choice a path already
|
||||
expresses.** `--config-source=db` names an implementation, not an operator
|
||||
intent, and a mode flag beside a path flag manufactures invalid
|
||||
combinations (path without mode, mode without path) that then need pairing
|
||||
rules and usage errors to defend. Presence-of-path has no invalid
|
||||
combinations and nothing to defend.
|
||||
|
||||
Breaking change, named: `run --config <file>` today means seed-once; the same
|
||||
syntax now means file authority — reconcile on every boot, UI config writes
|
||||
rejected. For an operator who seeded once and then configured through the UI,
|
||||
the first post-upgrade restart converges the DB to that old seed file,
|
||||
deleting the UI edits. The upgrade doc's breaking-changes section leads with
|
||||
this and gives the two exits (ruling 9): drop the flag, or re-export to the
|
||||
file path first. `check --config` keeps its meaning exactly. Greenfield rules
|
||||
apply — the seed-once interface does not survive for compatibility — but the
|
||||
break is loud in the docs, never silent-by-omission.
|
||||
|
||||
A rename (`--managed-config`) that would make the old invocation fail loudly
|
||||
was considered twice and declined: it trades a worse name and a permanent
|
||||
asymmetry with `check --config` against a one-time hazard whose exposed
|
||||
population is the pre-change install base of a project whose first release is
|
||||
days old. The hazard is real and the docs lead with it; the interface does
|
||||
not carry the scar. This is a judgment, recorded so it is revisited only with
|
||||
new facts (a real install base would be one).
|
||||
|
||||
### 2. File mode fails closed, and *declarative* failure is exit 2
|
||||
|
||||
File mode contract: the file is the sole declarative source; the DB stays the
|
||||
runtime substrate and the effective-config read path. Startup sequence,
|
||||
replacing the `seedFromFile` call at app.zig:196:
|
||||
|
||||
1. `DataDir.open` → open config DB → `migrate` (unchanged).
|
||||
2. Read the file (existing 4 MiB cap), ZON parse (arena, never freed — keep
|
||||
the import.zig discipline), `validate.validate`.
|
||||
3. Reconcile into the DB in one `BEGIN IMMEDIATE` transaction, `errdefer`
|
||||
rollback (ruling 3).
|
||||
4. `config_export.readConfig` (app.zig:206) → serve, unchanged from there on.
|
||||
|
||||
A missing, unreadable, or invalid file fails startup. Never fall back to the
|
||||
DB: a fallback turns a deploy typo into a silently stale config.
|
||||
|
||||
Exit codes keep the 2am contract — exit 2 means "your config is wrong, run
|
||||
`nxdns check`"; exit 1 means "the box is wrong". `faults.isConfigFault`
|
||||
deliberately excludes `FileNotFound`/`AccessDenied` (faults.zig:107-108), and
|
||||
that stays true in general. The file-mode loader is the seam, and it maps
|
||||
**path-class open failures only**: `FileNotFound`, `AccessDenied`,
|
||||
`PermissionDenied`, `NotDir`, `IsDir`, `SymLinkLoop`, `NameTooLong`,
|
||||
`BadPathName` become `error.ManagedConfigUnreadable` (message includes the
|
||||
path), which joins the `ConfigFault` set beside `ParseZon`/`ConfigTooLarge`.
|
||||
Every other member of `ReadFileAllocError` — `SystemResources`,
|
||||
`ProcessFdQuotaExceeded`, `SystemFdQuotaExceeded`, I/O errors, `OutOfMemory` —
|
||||
propagates unmapped, exit 1: those are box faults a retry can clear, and once
|
||||
ruling 9 adds `RestartPreventExitStatus=2 64`, mapping them to exit 2 would
|
||||
stop the unit permanently on a transient fault. The mapping is an explicit
|
||||
named error set in the loader, switched exhaustively with
|
||||
`else => |other| return other`, so a std error added in a Zig bump defaults to
|
||||
exit 1 rather than silently to exit 2 — the same closed-set discipline
|
||||
faults.zig already documents. The mapping lives in **one shared helper** used
|
||||
by both `run` and `check`; two copies would let the two grade the same
|
||||
unreadable file differently, exactly the divergence faults.zig:1-8 exists to
|
||||
abolish.
|
||||
|
||||
**Scope of the check/run agreement claim**: `check --config=<file>`
|
||||
grades exactly the declarative faults `run` would hit — read (path-class),
|
||||
parse, size, validate — through the same shared loader helper. Reconcile-time
|
||||
faults (FK violations, `SQLITE_BUSY` from a restart race, disk full) are
|
||||
runtime faults, exit 1, and structurally invisible to `check`, which needs no
|
||||
DB. The acceptance encodes the scoped claim, not "check passing guarantees run
|
||||
converges". The one *systematic* gap the red team found — a group removal
|
||||
tripping the un-cascaded `clients.group_id` FK against observed rows `check`
|
||||
cannot see — is eliminated in the engine itself (ruling 3's reassign rule),
|
||||
not papered over in `check`.
|
||||
|
||||
Reconcile-time diagnostics include the SQLite error (`SQLITE_FULL` by name
|
||||
when that is the cause) so a full SD card reads as "disk", not as a bare
|
||||
exit 1. Diagnostics render to `r.err` and flush immediately, keeping the
|
||||
current `seedFromFile` discipline (app.zig:137-178) — `serve` never returns,
|
||||
so a buffered error line is a lost error line.
|
||||
|
||||
Because every boot revalidates the file, a latent file error is no longer a
|
||||
first-boot-only hazard: a bad push that skips its restart handler detonates at
|
||||
the next power blip. Two mitigations, both in ruling 9: the shipped unit gains
|
||||
`RestartPreventExitStatus=2 64` (retrying a config fault every 2 s is pure
|
||||
loop; the journal holds the diagnostics), and the deployment docs mandate
|
||||
`nxdns check --config=<file>` as the pre-restart gate in any Ansible
|
||||
handler.
|
||||
|
||||
Db mode: steps 2-3 are skipped entirely; the DB is truth exactly as today.
|
||||
Bare `check` on a box with no `config.db` exits 2: `no config database at <path>`
|
||||
plus the ruling-6 hint line — the deleted heuristic's "nothing to check"
|
||||
branch (cli.zig:582-587) is replaced, not dropped.
|
||||
|
||||
### 3. Reconcile engine: replace declarative state, preserve runtime state by stable identity
|
||||
|
||||
New module `src/config/reconcile.zig`, replacing `applyToDb`'s wipe+reinsert.
|
||||
The defect it exists to fix: today's import deletes and reinserts
|
||||
`blocklist_sources` including runtime columns (checksum, `last_updated`,
|
||||
counters — config_schema.zig:46-56), and compiled blocklists are keyed by
|
||||
source row id (`<id>.list`/`<id>.wild`). A naive re-import every boot would
|
||||
force a full re-download and recompile of every blocklist on every restart.
|
||||
|
||||
Contract, per table: match rows by identity key ⇒ UPDATE declarative columns
|
||||
in place (row id survives); present in DB but absent from the file ⇒ DELETE;
|
||||
present in file but not DB ⇒ INSERT. Never wipe. One transaction. The
|
||||
`clients` table refines the match rule with promotion (below): an observed row
|
||||
whose IP the file declares is matched and updated, never deleted.
|
||||
|
||||
**Identity matching is on canonical forms.** Import already canonicalizes
|
||||
client IPs and prefixes before insert (import.zig:226-237; `FD00:0:0:0:0:0:0:1`
|
||||
stores as `fd00::1`) and the schema documents the columns as canonical text.
|
||||
The engine canonicalizes file values *before* matching; matching the raw file
|
||||
string against the canonical column would churn ids under an unchanged
|
||||
non-canonical file, violating ruling 5 in a way an exported-config test
|
||||
(export emits canonical forms) cannot catch. The idempotence test includes a
|
||||
non-canonical file.
|
||||
|
||||
**Writes only on difference.** A matched row whose declarative columns already
|
||||
equal the file's values gets no UPDATE. This is stronger than byte-stability:
|
||||
reconciling an unchanged file performs **zero writes**, so a no-op boot needs
|
||||
no WAL headroom and a querylog-full SD card cannot brick a file-mode restart
|
||||
that db mode would survive. Testable: the second reconcile returns an all-zero
|
||||
summary and `sqlite3_total_changes` does not move.
|
||||
|
||||
| Table | Identity key | Declarative columns | Runtime-owned (preserved on match) |
|
||||
|---|---|---|---|
|
||||
| `blocklist_sources` | `url` | `name`, `enabled`, `is_suggested` | **`id`**, `checksum`, `last_updated`, `domain_count`, `wildcard_count`, `skipped_regex_count` |
|
||||
| `groups` | `name` (`default` pinned to id 1, existing assert kept) | `safe_search` | `id` |
|
||||
| `clients` | canonical `ip` | `name`, `group_id`, `hand_edited=1` | `first_seen`, `last_seen`; observed rows (`hand_edited=0`) are kept wholesale; an observed row whose IP is now declared in the file is **promoted in place** (UPDATE `name`, `group_id`, `hand_edited` 0→1 — `first_seen`/`last_seen` untouched, row id survives, counts as `updated`); observed rows whose group is deleted are **reassigned to the default group** (below) |
|
||||
| `rules` | the full tuple `(group_id, pattern, kind, action)` — no natural key exists; the identity is a **multiset**, exact duplicate tuples pair off by count | (the tuple) | `id`, `created_at` (unmatched inserts stamp `now`) |
|
||||
| `upstreams` / `client_prefixes` / `forward_zones` / `local_records` | `url` / canonical `prefix` / `zone` / `(name, rtype, value)` | everything else | `id` — preserved as a consequence of the idempotence invariant (ruling 5), **not** for FK stability: nothing references these ids by FK and id-based REST mutations are rejected in file mode |
|
||||
| `group_sources` | `(group_id, source_id)`, resolved via the name/url maps | whole row | — |
|
||||
| `settings` | key | value via `settings_repo.putSetting` upsert, on difference only | delete keys not produced by `toSettings`, **except** `web.password_hash`, which the reconciler owns directly (ruling 4) |
|
||||
|
||||
**Pass order (binding).** `delete_order` alone under-specifies the engine:
|
||||
inserts need parents before children, and a group DELETE cascades through
|
||||
`rules`, `client_prefixes`, `group_sources` (config_schema.zig:35, :60, :67),
|
||||
which could silently undo child-table work and corrupt the diff counts if
|
||||
deletes ran first or interleaved. The order is:
|
||||
|
||||
- **Phase A — upserts, parents first**: `groups`, `blocklist_sources`, then
|
||||
the referrers (`clients`, `client_prefixes`, `rules`, `group_sources`,
|
||||
`upstreams`, `local_records`, `forward_zones`, `settings`), with the
|
||||
name→id / url→id maps built after the parent passes. Client promotion
|
||||
happens here, in the `clients` pass.
|
||||
- **Phase B — delete-absent, child first**, in `delete_order`
|
||||
(config_schema.zig:99). Because every declarative child of a dying group is
|
||||
itself absent from the file (validate guarantees file rules/prefixes
|
||||
reference file groups), the child passes have already deleted and counted
|
||||
them by the time the parent DELETE runs; the FK cascades become a safety
|
||||
net, never the accountant.
|
||||
- Immediately before the `groups` delete pass: observed clients
|
||||
(`hand_edited=0`) whose `group_id` belongs to a dying group are reassigned
|
||||
to the default group (id 1). `clients.group_id` has **no** ON DELETE clause
|
||||
(config_schema.zig:26) — without this step, removing or renaming a group
|
||||
that observed devices had been assigned to trips the FK mid-transaction:
|
||||
exit 1, restart loop, and a `check` that said the file was fine. The
|
||||
reassignment is the semantics we want anyway: the operator un-declared the
|
||||
group, not the devices.
|
||||
|
||||
Consequences that make the fix complete: an unchanged URL keeps id **and**
|
||||
checksum + counters + `last_updated` together, so `loadSource` never sees
|
||||
`.never_fetched`, `needsRefresh` does not fire spuriously (preserving
|
||||
`last_updated` matters — checksum alone is not enough), `sweepOrphans` never
|
||||
orphans `<id>.list`/`<id>.wild`, and a restart costs zero downloads. A changed
|
||||
URL is a new identity: new row, new id, fresh download — consistent with
|
||||
artifacts keyed by id. **Accepted trade, stated**: the old row's compiled
|
||||
`<old-id>.list`/`<old-id>.wild` are orphaned at commit and swept before the
|
||||
new source's first fetch succeeds, so a URL edit whose new host is down leaves
|
||||
that list unenforced until a fetch lands. Deferring the sweep until a
|
||||
successor fetch would need a cross-artifact lifecycle for an event that is
|
||||
rare, operator-initiated, and bounded by the scheduler's retry — not worth the
|
||||
machinery on a household box. The REST `updateSource` keeps runtime columns
|
||||
across a URL edit; in file mode that route is rejected (ruling 7), so the
|
||||
divergence is unreachable; in db mode the reconciler only runs via explicit
|
||||
`import`.
|
||||
|
||||
Related clock fix, same subsystem: `needsRefresh` is wall-clock arithmetic
|
||||
(`now - last >= interval`, manager.zig:1201-1202) and the Pi has no RTC. A
|
||||
fetch stamped while the clock was ahead (pre-NTP boot, restored image)
|
||||
suspends refresh until real time catches the future timestamp — and this
|
||||
design's idempotence would faithfully preserve the poison forever, having
|
||||
deleted the wipe that used to be the accidental reset lever. The engine's
|
||||
sibling fix: `needsRefresh` treats `last_updated > now` as refresh-due. One
|
||||
comparison, with a test.
|
||||
|
||||
Client promotion in place makes the `saved_clients` lift/merge/restore
|
||||
scaffolding (import.zig:268-361, including `merge_observed_timestamps_sql`)
|
||||
unnecessary: that machinery exists only because the wipe destroyed
|
||||
`first_seen`/`last_seen` and had to smuggle them across. With no wipe, the
|
||||
semantics it encodes — observed history survives declaration — are a plain
|
||||
UPDATE that never touches the timestamp columns. The scaffolding dies with
|
||||
the wipe (Deletions); its tests' semantics move to the promotion tests.
|
||||
|
||||
**Ordering invariant**: reconcile commits before `manager.reload`
|
||||
(app.zig:400) runs and before the scheduler's `sweepOrphans` can fire, so
|
||||
preserved ids and checksums are visible to the filter layer before any
|
||||
pruning. This holds by construction — reconcile completes inside `serve()`
|
||||
before the manager exists — and is enforced *behaviorally*, not by a
|
||||
statement-order unit test with nothing to grip: the restart-no-redownload
|
||||
integration test (Tests, below) fails if anything between reconcile and
|
||||
reload re-orders or wipes. No `serve()` restructuring is chartered for this.
|
||||
|
||||
The engine returns a summary (ruling 8's input and the cross-session
|
||||
contract):
|
||||
|
||||
```zig
|
||||
pub const TableCounts = struct { inserted: u32, updated: u32, deleted: u32 };
|
||||
pub const Summary = struct {
|
||||
groups: TableCounts, sources: TableCounts, clients: TableCounts,
|
||||
client_prefixes: TableCounts, rules: TableCounts, group_sources: TableCounts,
|
||||
upstreams: TableCounts, local_records: TableCounts, forward_zones: TableCounts,
|
||||
settings: TableCounts,
|
||||
auth_transition: enum { none, enabled, disabled, rotated },
|
||||
};
|
||||
```
|
||||
|
||||
`updated` counts only rows actually written (writes-on-difference);
|
||||
observed-client preservation counts nothing; promotion and reassignment count
|
||||
as `updated` on `clients`. `deleted` on `clients` means exactly one thing: a
|
||||
formerly declared (`hand_edited=1`) row absent from the file. An unchanged
|
||||
file yields an all-zero summary.
|
||||
|
||||
New repo verbs carry the engine (`sources_repo.upsertByUrl`,
|
||||
per-table `deleteWhereNotIn`-style helpers), not new call ordering. `applyToDb`
|
||||
and its wipe loop are deleted; import.zig keeps its parse/validate/diagnostics
|
||||
plumbing.
|
||||
|
||||
### 4. Password: the file overrides when it speaks, and only then
|
||||
|
||||
`model.Web` changes to `password: ?[]const u8 = null` and
|
||||
`password_hash: ?[]const u8 = null`. The settings bridge splits its skip
|
||||
policy by direction — `isSkipped` becomes two functions, because encode and
|
||||
decode need different sets:
|
||||
|
||||
- `isEncodeSkipped` = {`web.password`, `web.password_hash`}: `toSettings`
|
||||
stops emitting `web.password_hash` (the reconciler owns that settings row
|
||||
directly) and continues to never emit `web.password`.
|
||||
- `isDecodeSkipped` = {`web.password`} only: `fromSettings` **still loads**
|
||||
`web.password_hash` from the settings table — the reconciler owning the
|
||||
write does not mean the read path stops seeing it. Skipping it on decode
|
||||
would leave `cfg.web.password_hash` null on every read path
|
||||
(`config_export.readConfig` at export.zig:52, `mutations.loadConfig`), turn
|
||||
`authEnabled` false, and silently disable auth in both modes.
|
||||
- `decodeValue` gains an `.optional => try decodeValue(child, text)` arm
|
||||
(model.zig:408-421 has none today; an unskipped optional field is currently
|
||||
a `@compileError`). Absent key ⇒ default `null`; present ⇒ non-null.
|
||||
|
||||
`auth.authEnabled` becomes `(web.password_hash orelse "").len != 0`
|
||||
(auth.zig:62-63); app.zig:475's `.live_hash = .init(...)` unwraps with
|
||||
`orelse ""`. The cases:
|
||||
|
||||
- **Both set**: existing error (`PasswordAndHashBothSet`, import.zig:250,
|
||||
validate.zig:395 — the check ports to `!= null` on both fields).
|
||||
- **`password` present and empty**: rejected by `validate` with a new
|
||||
diagnostic naming the remedy — `password_hash = ""` is how auth is disabled
|
||||
declaratively. Without this rejection, `.password = ""` would hash the
|
||||
empty string into a non-empty PHC (`authEnabled` true) while auth.zig:90
|
||||
refuses every empty-password login: auth on, unreachable. A declarative
|
||||
fault, exit 2, so ruling 2's check/run agreement holds.
|
||||
- **`password` set (non-empty plaintext)**: verify against the stored
|
||||
`web.password_hash` with argon2; on match keep the stored hash, on mismatch
|
||||
or absent stored hash, hash fresh. The justification is ruling 5 alone:
|
||||
hashing unconditionally generates a fresh salt per apply and breaks
|
||||
byte-stability. It is **not** a cost saving — argon2 verification recomputes
|
||||
the full function (same t=2, m=19 MiB) with the stored salt, so
|
||||
verify-and-keep costs exactly what hashing costs. Do not "optimize" the
|
||||
verify away with any cached-plaintext scheme; that would be a security bug.
|
||||
- **`password_hash` set**: written verbatim. An explicit `password_hash = ""`
|
||||
is the declarative way to disable auth (auth.zig's documented empty-hash
|
||||
state).
|
||||
- **Neither set**: the stored hash is untouched. This is the deliberate
|
||||
carve-out from file-as-sole-truth, because the naive alternative is a trap
|
||||
the red team walked straight into: `toSettings` today always emits
|
||||
`web.password_hash` with default `""` (model.zig:534, :93), so an operator
|
||||
who hand-trims the ugly PHC string out of an exported file — meaning "keep
|
||||
the current password" — would silently reconcile `""` over the stored hash
|
||||
and open the admin UI to the LAN (`authEnabled` is `len != 0`, auth.zig:63).
|
||||
Silence must mean "keep", and disabling auth must require the explicit
|
||||
empty string.
|
||||
|
||||
**Export's canonical form**: `password = null`, `password_hash = <stored
|
||||
value, including "">`. A non-null `password` is never emitted — the current
|
||||
`cfg.web.password = "";` at export.zig:71 ported literally would make every
|
||||
export carry a present-empty password beside a stored hash, tripping
|
||||
`PasswordAndHashBothSet` on re-import: export's own output failing its own
|
||||
rule, breaking ruling 9's adoption walkthrough and ruling 5's round trip. The
|
||||
comment at export.zig:67-70 and the header sample change with it. With
|
||||
`password = null`, the empty-plaintext rejection above does not fire and the
|
||||
"hash written verbatim" branch keeps idempotence.
|
||||
|
||||
Any auth transition (enabled/disabled/rotated) is reported in the summary and
|
||||
printed by ruling 8 — an auth change is never a silent line item in a count.
|
||||
|
||||
### 5. Idempotence is the invariant
|
||||
|
||||
Reconciling the same file twice produces a byte-identical database — ids,
|
||||
checksums, timestamps, `created_at`, password hash, the whole settings
|
||||
table — **and** the second pass performs zero writes (all-zero summary,
|
||||
`total_changes` unmoved). The unit test asserts
|
||||
byte-stability via the `dump()` helper (import.zig:469), which is rewritten to
|
||||
iterate an explicit all-tables list (`config_schema.table_names`, all ten
|
||||
content-bearing tables) because its current driver, `content_tables`, is
|
||||
deleted (ruling 6). Anything that churns under an unchanged file — including a
|
||||
*non-canonical but equivalent* file — is a bug in the engine, by definition.
|
||||
(This invariant is what forces ruling 3's rules-multiset, canonical matching,
|
||||
and writes-on-difference, and ruling 4's password handling — the places a
|
||||
naive design silently violates it. It is also half of why ruling 8 persists
|
||||
no authority state at all.)
|
||||
|
||||
### 6. `import` becomes a thin wrapper over reconcile; the guard becomes diff-gated
|
||||
|
||||
`nxdns import` is db mode's one-shot apply and the restore tool, reimplemented
|
||||
on the reconcile engine. Reconcile is non-destructive of *runtime* state, but
|
||||
deletion of declarative rows absent from the file is still a first-class
|
||||
outcome (ruling 3) — a mistaken `nxdns import ./wrong.zon` against a
|
||||
configured DB would still remove every group, rule, upstream, and source not
|
||||
in that file. So the guard is **retargeted, not deleted**: emptiness-gating
|
||||
(`isEmpty`) becomes diff-gating.
|
||||
|
||||
- After the reconcile passes, still inside the same `BEGIN IMMEDIATE` —
|
||||
preserving import.zig:199-203's deliberate check-inside-the-write-lock
|
||||
property, no TOCTOU — if any table's `deleted != 0` and no override flag,
|
||||
roll back and fail exit 2 with the per-table delete counts in the message.
|
||||
Observed-client reassignment is not declarative data and never trips the
|
||||
gate (and under ruling 3's promotion rule, observed rows are never deleted
|
||||
by reconcile at all).
|
||||
- The flag is `--allow-delete` (`Options.allow_delete`), the renamed
|
||||
`--force`; the error is `error.DestructiveImport`, the renamed
|
||||
`DatabaseNotEmpty`, exit-2-mapped where cli.zig:533 maps today. This is
|
||||
strictly better than the emptiness guard: additive and edit-only re-imports
|
||||
stop needing a flag at all, and the flag now names what it permits.
|
||||
"Edit-only" means edits to declarative columns on a matched identity; an
|
||||
edit that *changes an identity column* — a group name, a source or upstream
|
||||
url, a prefix, a zone, a rule tuple, a local-record identity — is a delete
|
||||
plus an insert to the engine (ruling 3) and needs the flag. cli.md states
|
||||
the distinction.
|
||||
- `import.isEmpty` and `content_tables` still die — the diff-gate needs no
|
||||
table list.
|
||||
|
||||
`import` does not detect a file-managed DB, because nothing records one:
|
||||
authority lives in the invocation (ruling 1) and the DB carries no marker
|
||||
(ruling 8). On a box whose unit runs file mode, `import` behaves like any
|
||||
other import — the diff-gate guards deletion, and the next boot's reconcile
|
||||
converges the DB back to the file, its summary reporting what it corrected.
|
||||
The docs own this story plainly: `import` is a **stop-first operation**, on a
|
||||
file-mode box doubly so — against a running instance its effect is partial
|
||||
(the runtime divergence below) and lasts only until the next restart. The
|
||||
file-authority contract is stated with the same precision everywhere: the
|
||||
file is the sole declarative source, **converged at every boot** — not a
|
||||
lock on the database between boots. A detect-and-warn variant was
|
||||
designed and deleted (ruling 8) — a per-invocation warning cannot *prevent*
|
||||
db-side writes anyway, the CLI user is root on their own box, and the
|
||||
asymmetry with the web layer's 403 is intentional: the web UI has
|
||||
anonymous-ish LAN users, the CLI has the operator.
|
||||
|
||||
What a mid-run import against a *running* file-mode instance actually does —
|
||||
documented, because the divergence is partial, not merely deferred:
|
||||
`Manager.reload` re-reads `blocklist_sources`, `groups`, `group_sources`,
|
||||
`rules`, `clients`, `client_prefixes` from the DB at runtime
|
||||
(manager.zig:405-489) and the scheduler runs `sweepOrphans` before every pass
|
||||
(manager.zig:1095, :1115), so filtering follows the imported rows on the next
|
||||
reload and can unlink the compiled `<id>.list`/`<id>.wild` of sources the
|
||||
import deleted — while upstreams, listeners, and settings stay at boot
|
||||
values, and the web UI shows the imported state under the file-authority
|
||||
banner. The next restart's reconcile re-inserts deleted sources from the file
|
||||
with new ids: a full re-download, the exact cost this design exists to
|
||||
prevent. That is why the docs name the restart requirement and the
|
||||
re-download cost. The settings envelope's `reconciled_at` cannot see a CLI
|
||||
import — ruling 7's weakened claim covers exactly this.
|
||||
|
||||
The startup-vs-import race needs no code: both paths take `BEGIN IMMEDIATE`
|
||||
under the 5 s busy timeout (db.zig:252), so the outcome is ordering, not
|
||||
corruption — an import racing a restart may be reverted by the reconcile that
|
||||
wins the lock second. Documented, not engineered around.
|
||||
|
||||
First-run story in db mode, after bootstrap dies: a fresh empty DB fails
|
||||
validation naturally (`NoUsableUpstreams`, exit 2). The remediation hint —
|
||||
one fixed line naming `nxdns import` and `run --config` — is owned by
|
||||
**cli.zig's db-source fault renderer** (one arm, beside the exit-code mapping;
|
||||
Zig errors carry no text and validate.zig must stay mode-blind), and the same
|
||||
line serves bare `check` with no DB (ruling 2). One location, R2's charter.
|
||||
|
||||
`export` is unchanged in role: the diagnostic/capture tool in both modes, and
|
||||
the file-mode adoption tool (its canonical password form changes per
|
||||
ruling 4).
|
||||
|
||||
### 7. Web layer: policy as data, rejected after auth, plain 403
|
||||
|
||||
`WebState` gains `authority: union(enum) { database, managed_file: []const u8 }`
|
||||
and `reconciled_at: ?i64`, built where `WebState` is assembled (app.zig:468).
|
||||
The `managed_file` path slice is owned by `serve`'s arena, which outlives
|
||||
`WebState` — R2 provides it, R3 consumes it, neither copies. `reconciled_at`
|
||||
is stamped by `serve` immediately after the reconcile commits — same process,
|
||||
same frame, a local — and is `null` in db mode, which never reconciles.
|
||||
|
||||
`RouteInfo` gains `policy: enum { read, config_write, runtime_action }` beside
|
||||
`auth` and `rate_limit` — policy-as-data, matching the table's existing style.
|
||||
No default value: all 56 route entries state their class explicitly, and
|
||||
router.zig's `test_table` (:198) gains the field too. Classification is
|
||||
per-route, not per-prefix: `POST /api/blocklists/update` is a
|
||||
`runtime_action`; its CRUD siblings are `config_write`.
|
||||
|
||||
- Config writes, rejected in file mode: all group / blocklist / rule /
|
||||
local-record / forward-zone / upstream / client-prefix mutations,
|
||||
`PUT /api/settings` (password changes go through the file; its
|
||||
`live_hash.installAndRevoke` side effect never fires in file mode), and
|
||||
client PUT — naming or regrouping an observed client is declarative drift
|
||||
(open question 1).
|
||||
- Client DELETE is a **runtime action**: deleting an observed
|
||||
(`hand_edited=0`) row discards runtime state the file never declared —
|
||||
without this, a mis-identified or departed device's row is immortal in file
|
||||
mode, since the file can only promote IPs, never remove them. Deleting a
|
||||
*declared* client contradicts the file: the handler answers the same 403
|
||||
envelope. This is the one policy decision that needs a row read; it lives
|
||||
in the client handler, not the router. (After ruling 3's promotion, a
|
||||
declared IP's row *is* declared — DELETE answers 403, the intended
|
||||
reading.)
|
||||
- Runtime actions, always live: pause, blocklist refresh, cert reload,
|
||||
login/logout.
|
||||
|
||||
Enforcement lives in `router.dispatch` **after** `check_auth`, before the
|
||||
handler — match → rate limit → auth → policy. Pre-auth rejection would leak
|
||||
route existence; the codebase answers 401 first and this design keeps that.
|
||||
|
||||
Rejection is **403**, body the existing single-field envelope:
|
||||
`{"error":"configuration is managed by /etc/nxdns/config.zon; edit the file and restart"}`.
|
||||
Not 409 — that status already means constraint conflict four ways in
|
||||
openapi.yaml — and **no `code` field**: 403 is unused today, so the status
|
||||
alone is machine-readable; the UI learns authority declaratively from
|
||||
`GET /api/settings`, not by probing errors; and the single-property `Error`
|
||||
schema, golden contract samples, and `api.ts` stay untouched. An optional field
|
||||
with no consumer is machinery, not a contract.
|
||||
|
||||
The envelope must survive its own path: `respondError` today builds into a
|
||||
fixed 512-byte buffer and **silently downgrades to `text/plain`** on overflow
|
||||
(http_util.zig:315-327) — a long managed-file path (nested bind mounts) would
|
||||
demote the documented JSON envelope. Fix at the root: `respondError` gets the
|
||||
arena treatment `respondJson` already uses (`Writer.Allocating` over
|
||||
`request.arena`, :337-339) and the `respondPlain` silent-downgrade path is
|
||||
deleted — which fixes every long error message, not just this one.
|
||||
`src/web/http_util.zig` joins R3's ownership.
|
||||
|
||||
Authority discovery: the `GET /api/settings` envelope gains
|
||||
`authority: {mode, path, reconciled_at}` — an authenticated route, so the
|
||||
filesystem path never leaks through open `/api/version`/`/api/health`. All
|
||||
three come from `WebState` (live truth); `path` is present in file mode only
|
||||
and `reconciled_at` is nullable — `null` in db mode. Its meaning is exactly
|
||||
"**this process loaded the file at T**" — restart-pending detection: an mtime
|
||||
newer than `reconciled_at` means the running process has not loaded the
|
||||
current file. The comparison is one-directional and non-authoritative — a
|
||||
stepped clock (pre-NTP boot stamping the future, ruling 3's clock fix
|
||||
territory) or a preserved mtime (`git checkout`, `rsync -a`) can make a newer
|
||||
file look older, and the DB can move without either timestamp moving
|
||||
(ruling 6's `import`, this ruling's `runtime_action` routes). It does not
|
||||
answer "is the file what the server uses"; answering that would take content
|
||||
hashing, which the anti-requirements refuse. The UI renders a read-only
|
||||
banner (RestartBanner slot precedent) and disables mutation controls, with
|
||||
the 403 as backstop.
|
||||
|
||||
### 8. The operator can see what happened
|
||||
|
||||
- `logStartup` (after `logging.install`) logs the authority:
|
||||
`authority: database` / `authority: file (/etc/nxdns/config.zon)`.
|
||||
- In file mode, the reconcile summary (ruling 3's `Summary`) prints through
|
||||
the Runner at startup, matching `seedFromFile`'s existing output discipline:
|
||||
per-table inserted/updated/deleted counts, the changed settings **keys**
|
||||
(never values), and the auth transition when there is one. It is the answer
|
||||
to "what did that restart change" without opening sqlite.
|
||||
- **Authority is never persisted.** The DB carries no record of which mode
|
||||
wrote it: authority lives in the invocation (ruling 1), per-process state
|
||||
(`WebState.authority`, `reconciled_at`) serves the API (ruling 7), and the
|
||||
journal holds the history. A persisted marker was designed twice and
|
||||
deleted twice. A per-boot `reconciled_at` settings row breaks ruling 5
|
||||
outright — `dump()` iterates the settings table, so any row rewritten per
|
||||
reconcile forfeits byte-identity, and `putSetting` moves `total_changes`,
|
||||
forfeiting zero-writes. The write-on-difference `authority.mode`/`path`
|
||||
variant survived idempotence but cost a reserved key namespace, a
|
||||
`fromSettings` decode filter, and a reconciler sweep exemption — three
|
||||
mechanisms whose only consumer was one `import` warning (ruling 6). State
|
||||
that exists to power a courtesy message is not worth a namespace. The
|
||||
settings sweep's exemption list is therefore exactly one key:
|
||||
`web.password_hash` (ruling 4).
|
||||
|
||||
No `std.log.err` in any new code, per the standing spec rule for new code
|
||||
(the pre-existing calls in db.zig/tls_server.zig are out of scope).
|
||||
|
||||
### 9. Deployment and migration
|
||||
|
||||
- **systemd**: the shipped unit stays flagless (db default) and gains two
|
||||
lines. `RestartPreventExitStatus=2 64` — a config fault or usage error must
|
||||
not restart-loop every 2 s until StartLimitBurst; the journal holds the
|
||||
diagnostics and the fix is a file edit, not a retry. (Correct in db mode
|
||||
too: exit 2 means the config is wrong in either mode. And it is why
|
||||
ruling 2 keeps exit 2 to *declarative* faults only — a transient box fault
|
||||
mapped to exit 2 would stop the unit permanently.) And
|
||||
`ReadOnlyPaths=/etc/nxdns` — `ConfigurationDirectory=nxdns` makes systemd
|
||||
create the directory owned by the service user, so without this line the
|
||||
source-of-truth file is writable by the very process whose mutation routes
|
||||
file mode exists to disable; nxdns never writes `/etc/nxdns` in either mode,
|
||||
so the base unit ships the enforcement rather than recommending it. Docs
|
||||
show a drop-in for file mode: `ExecStart=` reset plus
|
||||
`--config=/etc/nxdns/config.zon`.
|
||||
- **Ansible / config-management**: the docs' deployment guidance mandates
|
||||
`nxdns check --config=<file>` as the handler precondition — validate
|
||||
the pushed file *before* restarting, so a typo is a failed deploy at noon,
|
||||
not a dead resolver at the next 3am power blip.
|
||||
- **docker**: compose ships file mode as its example —
|
||||
`command: ["run", "--config=/etc/nxdns/config.zon"]` (resolving old open
|
||||
question 4; the `:ro` mount at compose.yaml:15 already suggests it). This keeps the
|
||||
fresh-install and volume-loss stories working after bootstrap dies: a
|
||||
recreated `nxdns-data` volume reconciles from the mounted file on next
|
||||
start, which is *better* than the old seed-once self-heal. The binary's
|
||||
default stays db. The seed-once comment (compose.yaml:9-12) is rewritten.
|
||||
For db-mode-in-docker, the docs show the recovery one-liner
|
||||
(`docker compose run --rm nxdns import /etc/nxdns/config.zon` — the file is
|
||||
positional, `ImportArgs.file` at cli.zig:61, plus `--allow-delete` when the
|
||||
diff deletes) — without it, `restart: unless-stopped` plus exit 2 is an
|
||||
infinite crash loop (docker has no start limit) with no documented way out.
|
||||
- **Adopt file mode** on a UI-configured box: **stop the service first**, then
|
||||
`nxdns export --out /etc/nxdns/config.zon` →
|
||||
`nxdns check --config=/etc/nxdns/config.zon` → add the flag → start.
|
||||
Stop-first is load-bearing twice: any UI edit landing between a live
|
||||
export and the restart would be silently reverted by the first reconcile,
|
||||
and `check` refuses to grade a live database (immutable open, `WalPending`
|
||||
against the steady-state WAL, db.zig:222-226) so the pre-flight gate only
|
||||
works stopped. (Sync note, found in R4: the spec originally claimed
|
||||
`export` itself refuses against a live instance — it does not; `export`
|
||||
opens read/write and succeeds. The claim was corrected to name `check`.)
|
||||
Stopped,
|
||||
the first reconcile's summary is all-zero and writes nothing — blocklist
|
||||
state, compiled files, and client history all survive. Every unchanged boot
|
||||
after it writes nothing either.
|
||||
- **Leave file mode**: drop the flag (remove the drop-in), restart. The DB
|
||||
already holds the last reconciled state; nothing else needed.
|
||||
- **Binary downgrade from file mode**: no unit edit is needed — the old
|
||||
binary accepts `run --config` with seed-once semantics, and against the
|
||||
already-configured DB it ignores the file and serves the last-reconciled
|
||||
state. The rollback note states the consequence: file edits stop applying
|
||||
until the binary is upgraded again.
|
||||
- **Restore from backup**: db mode is stop → restore `config.db` → start,
|
||||
with the WAL caveat db.zig itself documents: take backups only from a
|
||||
stopped instance (or use `export`), and on restore delete any stale
|
||||
`config.db-wal`/`config.db-shm` beside the target — a mismatched WAL is
|
||||
silently discarded by SQLite, which turns "restore" into "lose the tail".
|
||||
Restoring an *exported file* into a populated DB via `import` is exactly
|
||||
the deleting case: back-up-and-restore.md documents `--allow-delete` there
|
||||
(ruling 6) — the flag choreography is rewritten, not removed. In file mode
|
||||
`config.db` is not the config backup — the file is; restore = redeploy the
|
||||
file. The query-log DB restores independently in both modes.
|
||||
|
||||
Migration honesty, replacing the earlier blanket claim: a db-mode install
|
||||
that never passed `--config` needs nothing. Anyone whose unit, wrapper, or
|
||||
compose `command` carries `run --config` (the documented seed-once invocation
|
||||
in first-run.md and three how-to labs) gets file authority at the first
|
||||
post-upgrade start — the seed file becomes the config, and UI edits made
|
||||
since seeding are deleted by the first reconcile. The upgrade doc's
|
||||
breaking-changes section leads with this and gives the two exits: drop the
|
||||
flag to keep the DB, or re-export to the file path first to adopt file mode
|
||||
cleanly (the walkthrough above). `check --config` keeps its meaning. Docker
|
||||
db-mode fresh installs must use the new compose or run `import` once. No
|
||||
schema migration.
|
||||
|
||||
### 10. Documentation is part of the change
|
||||
|
||||
`explanation/configuration-model.md` is rewritten around the two-mode model
|
||||
(its "why the database wins" argument becomes mode-scoped, not superseded).
|
||||
Seed-once claims are corrected in: the first-run tutorial, install-with-systemd,
|
||||
install-with-docker, upgrade (breaking-changes + rollback sections,
|
||||
ruling 9), back-up-and-restore (new restore semantics, WAL sidecars,
|
||||
`--allow-delete`), set-up-admin-authentication (the ruling 4
|
||||
absence/empty-string rule), troubleshoot, cli.md
|
||||
(`run`/`check`/`import`/`export`, `--allow-delete`), configuration.md
|
||||
(optional password fields), files-and-directories.md, api.md (settings
|
||||
envelope, 403, per-route rejectability), the compose.yaml comment,
|
||||
README/INSTALL.
|
||||
|
||||
## Deletions (complete list)
|
||||
|
||||
`config/bootstrap.zig` (+2 tests, S7 cases 15-18, + app.zig import/call sites
|
||||
:35/:142/:146 and tests :1124/:1237) · `import.isEmpty` (+6 tests, + the
|
||||
storage_integration_test.zig call sites :763/:795/:894, + the clients_repo.zig
|
||||
doc comments :6/:129) · `config_schema.content_tables` (+its test; `dump()`
|
||||
re-pointed to the new `table_names` list) · `app.seedFromFile` (+its app.zig
|
||||
tests) · `CheckArgs.config_explicit` · the `checkImpl` source heuristic ·
|
||||
`applyToDb`'s wipe loop, including the `saved_clients` lift/merge/restore
|
||||
scaffolding (import.zig:268-361 — superseded by ruling 3's
|
||||
promotion-in-place) · `toSettings`'s `web.password_hash` emission (+its
|
||||
key-list test row; the model.zig:665-670 "skipped in both directions" test is
|
||||
rewritten for the encode/decode split) · `http_util.respondPlain`'s
|
||||
silent-downgrade path (`respondError` rebuilt on the request arena).
|
||||
|
||||
Renamed, not deleted (ruling 6): `Options.force` → `Options.allow_delete`
|
||||
(`--force` → `--allow-delete`), `error.DatabaseNotEmpty` →
|
||||
`error.DestructiveImport` (cli.zig:533 arm retargeted, faults.zig exclusion
|
||||
and tests follow the name).
|
||||
|
||||
## Sessions
|
||||
|
||||
R1 lands first and R2 rewires onto it and carries every deletion — they are
|
||||
ordered, not parallel (the old plan had R1 deleting `bootstrap.zig` out from
|
||||
under R2-owned app.zig call sites). R1 is **additive engine plus one
|
||||
coordinated model change**, not purely additive: the `model.Web` optional
|
||||
fields force same-session edits at every site that reads them, or the tree
|
||||
stops building. R3 and R4 run parallel to both; their interfaces
|
||||
(`reconcile.Summary`, `WebState.authority` + `reconciled_at`,
|
||||
`RouteInfo.policy`) are fixed here.
|
||||
|
||||
### Session R1: reconcile engine + the coordinated model change
|
||||
|
||||
Owns `src/config/reconcile.zig` (engine + `Summary` as specified), the new
|
||||
repo verbs in `src/storage/repositories/`, the `needsRefresh` clock clamp in
|
||||
`src/filter/manager.zig`, registration in `src/tests.zig`. Rulings 3, 4, 5.
|
||||
|
||||
Owns `src/config/model.zig` whole: the optional `Web.password`/`password_hash`
|
||||
fields, the `isEncodeSkipped`/`isDecodeSkipped` split, `decodeValue`'s
|
||||
`.optional` arm, and the bridge tests. And the sites the
|
||||
optional fields break, which no session previously owned: `validate.zig`
|
||||
(:395 both-set port, the new empty-password rejection, test :1966-1968),
|
||||
`auth.zig` (`authEnabled` orelse), `export.zig` (canonical `password = null`,
|
||||
comment and header sample), `src/web/handlers/settings.zig` (`newPassword`'s
|
||||
`orelse` chain at :184-186, and `FieldType` collapsing `?T` so `Partial`'s
|
||||
`?FieldType(...)` at :114 does not generate a double optional — landing this
|
||||
in R1 keeps R3's `web/` work unblocked). R1 also makes the one-line
|
||||
`orelse ""` edit at app.zig:475 so the tree builds; app.zig otherwise stays
|
||||
R2's, and R2 absorbs that line into its startup rewire.
|
||||
|
||||
### Session R2: CLI + startup + deletions
|
||||
|
||||
Owns `src/cli.zig`, `src/app.zig`, `src/config/faults.zig`,
|
||||
`src/config/import.zig` (thin-wrapper rewrite, diff-gate, `--allow-delete`),
|
||||
the shared loader-fault mapping helper (ruling 2, used by `run` and `check`),
|
||||
and the entire Deletions list plus the renames. Rulings 1, 2, 6, 8. Threads authority and
|
||||
`reconciled_at` into `WebState` (arena-owned path) but does not touch the
|
||||
router. Owns the cli.zig hint-line renderer arm.
|
||||
|
||||
### Session R3: web enforcement + UI
|
||||
|
||||
Owns `src/web/router.zig` (dispatch policy step, `test_table` gains the
|
||||
policy column), `src/web/routes.zig` (all 56 entries state `policy`
|
||||
explicitly — no default), `src/web/http_util.zig` (`respondError` arena
|
||||
rewrite, `respondPlain` downgrade deletion), `src/web/openapi.yaml`, the
|
||||
client-DELETE observed/declared branch in the clients handler, `web/`
|
||||
frontend (banner, disabled controls, settings envelope with nullable
|
||||
`reconciled_at`). Ruling 7. Consumes `WebState.authority`/`reconciled_at` as
|
||||
fixed above.
|
||||
|
||||
### Session R4: deployment + docs
|
||||
|
||||
Owns `deploy/**`, `docs/**`, `README.md`, `INSTALL`, compose file + comment,
|
||||
the systemd unit's two new lines. Rulings 9, 10.
|
||||
|
||||
### Orchestrator
|
||||
|
||||
This spec, R1→R2 sequencing, cross-session integration, the drift-guard
|
||||
regenerations that span sessions (contract samples, openapi route counts,
|
||||
api.md/cli.md rows), and the `-Dlive` acceptance run.
|
||||
|
||||
## Tests
|
||||
|
||||
- **Reconcile unit tests** (R1, `:memory:` + migrate): idempotence via the
|
||||
rewritten `dump()` byte-stability across all tables after applying the same
|
||||
file twice — including a file carrying a plaintext password **and a
|
||||
non-canonical but equivalent file** (`FD00::1`-style) — plus the zero-writes
|
||||
assertion (all-zero `Summary`, `total_changes` unmoved) on the second pass;
|
||||
per-table preservation via `sources_repo.SourceRow` after seeding stats with
|
||||
`updateSourceStats`; URL change ⇒ new id; source removal; observed-client
|
||||
survival, **promote-on-declare (asserting `first_seen`/`last_seen` survive
|
||||
the promotion and the row id is stable)**, and **reassign-to-default when
|
||||
their group is removed or renamed** (the un-cascaded FK case);
|
||||
`safe_search` edit on an existing group converges; rules `created_at`
|
||||
stability including duplicate tuples; password verify-keeps-hash,
|
||||
mismatch-rehashes, absent-keeps-stored, explicit-empty-disables; rollback
|
||||
on mid-tx failure; default-group id 1 pin; `needsRefresh`
|
||||
future-`last_updated` clamp.
|
||||
- **Model/bridge tests** (R1, model.zig test neighbourhood at :659):
|
||||
`decodeValue` on an optional field (absent ⇒
|
||||
null, present ⇒ value); `web.password_hash` decodes from settings but is
|
||||
not encoded (the encode/decode split); `validate` rejects present-and-empty
|
||||
`password` with the `password_hash = ""` remedy in the diagnostic; export ⇒
|
||||
import round trip with the canonical `password = null` form.
|
||||
- **Import gate tests** (R2): a file whose diff deletes rows, without
|
||||
`--allow-delete` ⇒ rollback, exit 2, per-table delete counts in the
|
||||
message; with the flag ⇒ applied; additive/edit-only import needs no flag.
|
||||
- **Loader-fault mapping** (R2, on the shared helper directly): each
|
||||
path-class open error ⇒ `ManagedConfigUnreadable`; a non-path member of the
|
||||
set propagates unmapped.
|
||||
- **Restart-no-redownload** in filter_integration_test.zig: reconcile, then a
|
||||
`Manager` restart reuses `<id>.list`/`<id>.wild` with no refetch (fixtures
|
||||
already assert reuse by id) — this is also the behavioral enforcement of
|
||||
ruling 3's ordering invariant. Plus **one `-Dlive` run of the real download
|
||||
path** — hermetic suites have hidden a process-killing bug on the real
|
||||
network path before (MEMORY), so the acceptance includes the real path once.
|
||||
- **Router**: policy classification unit tests via `test_table`/`matchPath`,
|
||||
no sockets; the contract table in web_integration_test.zig gains a `policy`
|
||||
column and the existing 1:1 coverage assertion widens so classification
|
||||
cannot drift; one socketed test per class in file mode (config write → 403,
|
||||
runtime action → 2xx, read → 200) plus observed-vs-declared client DELETE,
|
||||
via `EnvOptions` authority; `respondError` with a message longer than the
|
||||
old 512-byte buffer stays `application/json`. Route count stays 56 — no new
|
||||
routes.
|
||||
- **CLI**: `parseArgs` tests: `run` without `--config` selects db authority;
|
||||
`run --config=<path>` selects file authority and the path lands in the run
|
||||
args; `--allow-delete` parsing; `usage_text` test; exit-2 for a missing
|
||||
managed file and for bare `check` with no DB (hint line present) — via the
|
||||
`Captured` runner (in-process; remember the buffered-writer caveat — flush
|
||||
through a real `File.Writer` where the test asserts delivery).
|
||||
- **Drift guards knowingly tripped and regenerated**: openapi.yaml (settings
|
||||
envelope, 403 responses), golden contract samples (`-Dcontract-samples-out`),
|
||||
api.md rows, cli.md sections, docs_drift_test, `toSettings` key-list test.
|
||||
|
||||
## Acceptance (design complete when implemented)
|
||||
|
||||
- [x] `nxdns run --config=<file>` on a DB with fetched blocklists (any boot
|
||||
after adoption): restart performs zero downloads **and zero DB writes**;
|
||||
source ids, checksums, `last_updated`, and compiled
|
||||
`<id>.list`/`<id>.wild` files are identical before and after. Proven
|
||||
once with `-Dlive` against a real source. *(Closed by
|
||||
filter_integration test 10e: a hermetic HTTP fixture counts accepted
|
||||
connections across a full Manager restart — 1 download total; inode,
|
||||
mtime and source ids identical. The `-Dlive` gate passed 1559/1559
|
||||
with 0 skips, which includes this test against the fixture and the
|
||||
live-network suites against real upstreams.)*
|
||||
- [x] Reconciling an unchanged exported config twice yields a byte-identical
|
||||
`dump()` and an all-zero summary — including with a plaintext
|
||||
`password` in the file, and with non-canonical addresses.
|
||||
- [x] Removing (and renaming) a group that observed clients were assigned to
|
||||
converges: clients land in the default group, no FK error, counts
|
||||
reported. Declaring an observed client's IP promotes the row in place:
|
||||
`first_seen`/`last_seen` and row id survive, counted as `updated`.
|
||||
- [x] A file with neither `password` nor `password_hash` leaves the stored
|
||||
hash — and auth — intact; `password_hash = ""` disables auth and the
|
||||
startup summary says so; a present-but-empty `password` is refused at
|
||||
validate with a diagnostic naming `password_hash = ""` as the disable
|
||||
path.
|
||||
- [x] `run --config=<file>` with a missing file exits 2 with the path in
|
||||
the message; with an invalid file exits 2 with diagnostics; never serves
|
||||
from the DB. An open failure outside the path class (fd exhaustion, I/O
|
||||
error) exits 1, not 2. `check --config=<file>` agrees with `run`
|
||||
on every parse/validate/path fault via the shared helper (the scoped
|
||||
claim of ruling 2).
|
||||
- [ ] A config file present at `/etc/nxdns/config.zon` with no `--config`
|
||||
flag changes nothing: bare `run` serves the DB and never reads the
|
||||
file. *(Design claim, verified structurally: no code path opens
|
||||
`/etc/nxdns` — the deletion gate below proves the seed-by-presence
|
||||
path is gone, and tests cover bare `run` with a config file present
|
||||
in a lab directory. Not executed against the literal path
|
||||
`/etc/nxdns/config.zon` on a host that has one; this machine does
|
||||
not run nxdns from /etc.)*
|
||||
- [x] `nxdns import` whose diff would delete rows fails exit 2 without
|
||||
`--allow-delete`, printing per-table delete counts, and rolls back;
|
||||
with the flag it applies; an additive import needs no flag.
|
||||
- [ ] Fresh empty DB in db mode exits 2 (`NoUsableUpstreams`) with the hint
|
||||
line; bare `check` with no `config.db` exits 2 with the
|
||||
same hint; neither restart-loops under the shipped unit
|
||||
(`RestartPreventExitStatus=2 64`).
|
||||
*(Exit codes and hint lines are test-covered and closed. The
|
||||
no-restart-loop half is a design claim: the unit line parses under
|
||||
`systemd-analyze verify`, but no root systemd host was available to
|
||||
observe systemd actually holding the unit down. Close it on the Pi 5
|
||||
deployment.)*
|
||||
- [x] The shipped compose file boots a fresh container (empty volume, mounted
|
||||
config.zon) into file mode successfully; the db-mode import recovery
|
||||
one-liner is documented and works. *(Run against a locally built
|
||||
image: first boot reconciled `upstreams +1 ~0 -0; settings +45` with
|
||||
auth enabled; second boot logged no changes.)*
|
||||
- [x] In file mode: every `config_write` route answers 403 with the
|
||||
single-field error envelope — `application/json` even when the managed
|
||||
path is long; every `runtime_action` and `read` route behaves as in db
|
||||
mode; DELETE of an observed client succeeds, of a declared client
|
||||
answers 403; unauthenticated requests to protected routes still answer
|
||||
401, not 403.
|
||||
- [x] `GET /api/settings` reports `authority` with `reconciled_at` (null in
|
||||
db mode); the UI shows the read-only banner and disables mutation
|
||||
controls in file mode.
|
||||
- [x] `rg -n 'import\.isEmpty|content_tables|config/bootstrap|seedFromFile|config_explicit' src/`
|
||||
returns nothing (historical specs exempt; pattern chosen so
|
||||
fetcher.zig's `host.isEmpty()` and validate.zig's "bootstrap problem"
|
||||
prose cannot false-positive).
|
||||
- [x] Adopt-file-mode walkthrough (stop → `export` → `check --config` → add
|
||||
the flag → start) run end to end on a UI-configured instance: the first
|
||||
reconcile summary is all-zero and writes nothing, as does every
|
||||
unchanged boot after it; leave-file-mode (drop the flag, restart)
|
||||
serves identically; `check` against the *running* instance's database
|
||||
refuses with the uncheckpointed-WAL message, as documented (corrected
|
||||
from `export`, which opens read/write and succeeds live — see ruling 9's
|
||||
sync note).
|
||||
- [x] All existing gates pass; tripped drift guards are regenerated, not
|
||||
suppressed. *(Final numbers: `zig build test` 1429/1559 pass, 130
|
||||
skipped, 0 failed; `-Dintegration` 1555/1559, 4 skipped, 0 failed;
|
||||
`-Dintegration -Dlive` 1559/1559, 0 skipped, 0 failed. The live gate
|
||||
also caught and fixed storage S7 case 22, whose expectation had been
|
||||
stale since milestone 13 because nothing ran `-Dlive` in between.)*
|
||||
|
||||
## Anti-requirements
|
||||
|
||||
- No file watcher, no inotify, no SIGHUP reload — restart is the reload.
|
||||
- No UI write-back to the file, no partial/merge authority, no per-table
|
||||
hybrid modes, no multi-file config.
|
||||
- No persisted authority state, no config hashing, no content-based change
|
||||
detection — authority lives in the invocation; last-load time is
|
||||
per-process state in the settings envelope, honest about what it can and
|
||||
cannot answer (ruling 7).
|
||||
- No deferred deletion of a replaced source's compiled artifacts — the URL-edit
|
||||
gap is an accepted trade (ruling 3), bounded by the scheduler retry.
|
||||
- No etag/conditional GET for blocklist fetches.
|
||||
- No `code` field or richer error envelope — the status is the machine
|
||||
contract.
|
||||
- No rule-identity schema change (no synthetic rule key column).
|
||||
- No preservation of rule `created_at` across pattern edits — an edited rule
|
||||
is a new rule.
|
||||
- No CLI-side blocking or detection of `import` against a file-mode box's
|
||||
DB — the diff-gate guards deletion; the next boot converges and its
|
||||
summary reports what it corrected.
|
||||
- No fallback from file mode to db mode under any failure.
|
||||
|
||||
## Resolved defaults (were open questions; the user may overrule before R3/R4)
|
||||
|
||||
1. **UI naming and regrouping of observed (`hand_edited=0`) clients in file
|
||||
mode: rejected as declarative drift** (client PUT stays `config_write`,
|
||||
ruling 7). The household user adds the client to the file instead. The
|
||||
alternative — carving naming/grouping out as a runtime action —
|
||||
reintroduces two-way merge for one table, which the anti-requirements
|
||||
refuse. Affects R3.
|
||||
2. **`export` output is byte-identical in both modes** — no file-mode
|
||||
annotation. An annotation would make export → file → adopt produce a
|
||||
different file than the one checked, for a label the operator already has
|
||||
in the unit file. Affects R4's round-trip docs.
|
||||
3. **File mode ships as a documented systemd drop-in**, not a second
|
||||
commented `ExecStart` in the packaged unit. The packaged unit stays
|
||||
flagless and correct by itself; a commented alternative line in a unit
|
||||
file is a doc pretending to be config. Affects R4.
|
||||
|
||||
## Cross-validation findings rejected
|
||||
|
||||
Round 1: none — all twelve findings (F1-F12) were confirmed against the repo
|
||||
and are folded into the rulings above. The F1 remedy has since been
|
||||
superseded: the premise revision deleted the persisted marker entirely
|
||||
(ruling 8) instead of keeping it write-on-difference.
|
||||
|
||||
Round 2 (on the premise revision) returned three important findings and one
|
||||
minor. Folded: the docker recovery one-liner used a `--config` flag `import`
|
||||
does not have (fixed in ruling 9, file is positional); `import` is now
|
||||
stated everywhere as a stop-first operation and the file-authority contract
|
||||
as "converged at every boot" (ruling 6); identity-column edits are named as
|
||||
delete-plus-insert needing `--allow-delete` (ruling 6). Declined: renaming
|
||||
`run --config` to `--managed-config` — reasons recorded in ruling 1.
|
||||
|
||||
## Red-team findings rejected
|
||||
|
||||
None rejected outright — every finding checked out against the repo. Two
|
||||
proposed remedies were declined while their findings were accepted:
|
||||
|
||||
- **ops 10's mitigation** (keep the old compiled artifact until the successor
|
||||
URL's first successful fetch): declined — a cross-artifact lifecycle for a
|
||||
rare, operator-initiated event on a household box; the trade is now stated
|
||||
in ruling 3 and the anti-requirements instead.
|
||||
- **ops 14's db-mode warning** ("a config file exists but authority is
|
||||
database"): declined — db mode is given no file path, so warning would mean
|
||||
probing a well-known location, which is precisely the ambient inference
|
||||
ruling 1 bans; the accepted half (drift visibility) is served by the
|
||||
settings envelope's `authority` block and per-process `reconciled_at`.
|
||||
@@ -0,0 +1,342 @@
|
||||
# Milestone 21: ABP exceptions from lists, regex rules for operators
|
||||
|
||||
Goal: honor `@@||domain^` exception lines in downloaded blocklists as allow
|
||||
entries scoped below operator rules and above list blocks (tier 1), and add a
|
||||
`regex` rule kind for operator rules backed by a homegrown linear-time engine
|
||||
(tier 2). Nothing else from the ABP syntax enters scope.
|
||||
|
||||
Design written 2026-08-09 against HEAD `ffc3ca6`; every anchor re-verified
|
||||
2026-08-11 against `a8e0fe4` after milestone 20 landed. The milestone amends
|
||||
PLAN §2.2, which currently rules regex out permanently; the amendment is part
|
||||
of session S3, not a side effect.
|
||||
|
||||
## Implementation contract (read first)
|
||||
|
||||
- Read `AGENTS.md`, then this spec whole, before session work starts.
|
||||
- Pure core stays pure: `src/filter/` files that are fuzz-module roots import
|
||||
only `std` (`src/filter/parsers.zig:1-14`). The new `src/filter/regex.zig`
|
||||
obeys the same constraint.
|
||||
- Every new `src/**.zig` file must be listed in `src/tests.zig`
|
||||
(`build.zig:494-539` fatals otherwise).
|
||||
- Frozen DDL is frozen: schema changes are new migration steps
|
||||
(`src/storage/config_schema.zig:1-6`, `src/storage/migrations.zig:21-22`).
|
||||
- After any API shape change, regenerate the contract samples
|
||||
(`web/src/lib/contractSamples.gen.ts`; procedure in AGENTS.md).
|
||||
|
||||
## Rulings (binding)
|
||||
|
||||
### 1. Exception lines are `@@||name^` and nothing else, plus one modifier
|
||||
|
||||
`src/filter/parser_abp.zig:22` currently maps every `@@` line to
|
||||
`.unsupported`. After this milestone, a line is an exception when it is
|
||||
`@@||name^` or `@@||name` (same trailing-`^` and `rule_tokens` treatment as the
|
||||
block anchor at parser_abp.zig:26-34), optionally suffixed with the literal
|
||||
`$important` — that suffix is the common form in AdGuard-authored lists and
|
||||
changes nothing about the meaning here, because list exceptions already sit
|
||||
below every operator rule. Any other `@@` form (`@@name` without the anchor,
|
||||
any other `$` modifier, a path, a scheme) stays `.unsupported`. The
|
||||
parser-header policy paragraph (parser_abp.zig:5-6) is rewritten to state the
|
||||
new rule and its precedence justification: a list exception can cancel only
|
||||
list blocks, never an operator decision, so no downloaded list can open an
|
||||
allow hole the operator did not open.
|
||||
|
||||
### 2. Precedence: list exceptions sit between operator rules and list blocks
|
||||
|
||||
`matcher.Snapshot.evaluate` (`src/filter/matcher.zig:286-338`) gains one level
|
||||
between the operator wildcard-block walk (level 4) and the blocklist domain
|
||||
probe (level 5): for each attached source, an exception match — full name or
|
||||
parent walk, apex covered — returns `blocked = false`, reason
|
||||
`.blocklist_exception`, `matched` = the matching entry, `source` = the source
|
||||
index. The doc comment at matcher.zig:269-285 and PLAN §3.10 (PLAN.md:108-117)
|
||||
are both updated with the new level. Regex rules (ruling 6) slot in as levels
|
||||
after the wildcard rules and before list exceptions, allow before block, so
|
||||
the full order is: exact allow, exact block, wildcard allow, wildcard block,
|
||||
regex allow, regex block, list exception, list domain, list wildcard.
|
||||
"Tie-break at same specificity: allow wins" is preserved.
|
||||
|
||||
### 3. The compiled-source format grows a third body, checksum-compatibly
|
||||
|
||||
`compiler.compile` (`src/filter/compiler.zig:50-56`) takes a third writer
|
||||
(`allow_w`) and `Counts` (compiler.zig:23-35) gains `exceptions: u32 = 0`.
|
||||
Exception candidates go through the existing `addCandidate` path into a third
|
||||
`Entries` and emit as a sorted, deduplicated `.allow` body. The shared SHA-256
|
||||
covers the bodies in order list, wild, allow — because SHA-256 of
|
||||
`list ++ wild ++ ""` equals the current SHA-256 of `list ++ wild`, every
|
||||
already-published checksum stays valid, and `Manager.loadSource`
|
||||
(`src/filter/manager.zig:543-598`) treats a missing `<id>.allow` file as an
|
||||
empty body. No refetch is forced by upgrading. `bodyChecksum`
|
||||
(manager.zig:1572) follows the same order; `source_file_suffixes`
|
||||
(manager.zig:1589) gains `.allow.tmp` and `.allow` with longest-suffix-first
|
||||
order preserved; the on-disk header (manager.zig:221-241) gains
|
||||
`# exceptions {d}` after the `# wildcards` line and the pinning test at
|
||||
manager.zig:1914-1946 is extended, not weakened.
|
||||
|
||||
### 4. Exception counts persist and surface
|
||||
|
||||
Migration step 3 (`ddl_v3`, appended at `src/storage/migrations.zig:23-26`):
|
||||
`ALTER TABLE blocklist_sources ADD COLUMN exception_count INTEGER NOT NULL
|
||||
DEFAULT 0;`. `sources_repo.SourceRow` and `updateSourceStats`
|
||||
(`src/storage/repositories/sources_repo.zig:80-93,159-169`) carry it, and the
|
||||
checksum doc line at sources_repo.zig:100 is updated alongside
|
||||
`bodyChecksum`'s per ruling 3;
|
||||
`SourceStatus` rehydration (`manager.zig:1458-1502`) restores it alongside the
|
||||
existing three counts; `StatusView` (`src/web/handlers/blocklists.zig:52-78`)
|
||||
gains `exceptions: u32`; the blocklists UI shows it where `skipped_regex`
|
||||
already shows (`web/src/features/blocklists/SourceStatusSection.tsx`,
|
||||
`web/src/lib/types.ts:163,192`).
|
||||
|
||||
### 5. The regex engine is a Pike VM, linear-time by construction, `std` only
|
||||
|
||||
New file `src/filter/regex.zig`. Syntax: literal bytes, `.`, character
|
||||
classes `[...]` with ranges and leading-`^` negation, escapes
|
||||
`\. \\ \- \d \w`, repetition `* + ? {n} {n,m}`, alternation `|`,
|
||||
non-capturing grouping `(...)`, anchors `^` and `$`. No backreferences, no
|
||||
lookaround, no captures. Matching is unanchored unless anchors are written
|
||||
(POSIX-grep convention, matching Pi-hole user expectations). Input is the
|
||||
normalized lowercase name, ≤ `types.max_name_len` bytes. Hard limits, each a
|
||||
distinct error: pattern ≤ 256 bytes (`PatternTooLong`), compiled program
|
||||
≤ 1024 instructions (`PatternTooComplex`). Public API:
|
||||
|
||||
```zig
|
||||
pub const Error = error{ OutOfMemory, BadPattern, PatternTooLong, PatternTooComplex };
|
||||
pub const Program = struct { ... , pub fn deinit(self: *Program, gpa: Allocator) void };
|
||||
pub fn compile(gpa: Allocator, pattern: []const u8) Error!Program;
|
||||
pub fn matches(prog: *const Program, input: []const u8) bool;
|
||||
```
|
||||
|
||||
`matches` is a Pike VM: two thread lists, each program counter admitted at
|
||||
most once per input position, worst case O(program × input) with zero
|
||||
allocation at match time (thread lists sized from the program at compile
|
||||
time). The engine is a fuzz-module root like parsers.zig and imports only
|
||||
`std`.
|
||||
|
||||
### 6. `regex` is a third rule kind, validated at the edge, memoized by the cache
|
||||
|
||||
Migration step 4 (`ddl_v4`): the 12-step rebuild of `rules` with
|
||||
`CHECK(kind IN ('exact','wildcard','regex'))` — the frozen v1 DDL
|
||||
(`src/storage/config_schema.zig:65-72`) cannot be edited. The rebuilt table
|
||||
keeps the name `rules`: `config_schema.table_names`
|
||||
(config_schema.zig:112-119) and the invariant tests at
|
||||
config_schema.zig:120-127 and migrations.zig:356 assert the schema's table
|
||||
set, and a rename would fail both. `model.RuleKind`
|
||||
(`src/config/model.zig:259-275`) gains `.regex`; the exhaustive switches in
|
||||
`config/validate.zig:1099-1130` (compile the pattern, report
|
||||
`"... is not a valid regex pattern"` through the existing error path at
|
||||
validate.zig:891-902) and `src/filter/rules.zig:62-68` extend. `RuleSet`
|
||||
(`rules.zig:28-36`) grows `regex_allow` and `regex_block` slices holding
|
||||
compiled `Program`s plus their pattern texts (for `Decision.matched`);
|
||||
`bucketOf` (rules.zig:133-143) becomes a six-bucket layout;
|
||||
`max_regex_per_group: usize = 256` with `TooManyRegexRules` mirroring
|
||||
`max_wildcards_per_group` (rules.zig:26). A pattern that fails to compile is
|
||||
`error.BadPattern` at snapshot build, never skipped (rules.zig:41-49 doc
|
||||
holds). Reason tags `rule_allow_regex` and `rule_block_regex` join
|
||||
`matcher.Reason` (matcher.zig:24-32); `/api/lookup` and the query log pick
|
||||
them up automatically via `@tagName` (`src/web/handlers/lookup.zig:89`;
|
||||
`max_reason_len = 32` in `src/storage/logger.zig` fits both at 16 chars).
|
||||
Regex evaluation runs only after every hash and wildcard level missed, and
|
||||
answers are memoized by the existing DNS cache like every other decision, so
|
||||
the per-query cost lands on cache misses only.
|
||||
|
||||
### 7. The web contract names the third kind everywhere it names the first two
|
||||
|
||||
`src/web/handlers/rules.zig`: `toInput` accepts `"regex"`; the 400 string at
|
||||
rules.zig:42 becomes `"kind must be 'exact', 'wildcard' or 'regex'"`.
|
||||
`src/web/openapi.yaml:1948,1962,1976`: all three `enum: [exact, wildcard]` become
|
||||
`[exact, wildcard, regex]`. `web/src/lib/types.ts:195`:
|
||||
`RuleKind = "exact" | "wildcard" | "regex"`; the rules page kind selector
|
||||
gains the option. Contract samples regenerated. `nxdns export` / `import`
|
||||
round-trip the new kind with no extra work once `RuleKind.toDb/fromDb` extend
|
||||
— the enum round-trip test at model.zig:782 is extended to prove it.
|
||||
|
||||
### 8. PLAN amendments land with the code, in S3
|
||||
|
||||
PLAN.md:36 (§2.2) is rewritten: operator regex rules are in scope, backed by
|
||||
the linear-time engine of ruling 5; regex lines in downloaded lists stay
|
||||
counted and skipped; `$` modifiers (except the `$important` suffix of ruling
|
||||
1), partial-segment wildcards, and browser-syntax honoring stay permanently
|
||||
out. PLAN.md:26 (§2.1 filtering sentence), PLAN.md:106 (§3.9), PLAN.md:108-117
|
||||
(§3.10 precedence) and PLAN.md:700 (decision B) are updated to match rulings
|
||||
2 and 6. In-code echoes of the old §2.2 move with it:
|
||||
`src/filter/wildcard.zig:6-7,20-23`, `src/filter/parsers.zig:25`,
|
||||
`src/filter/parser_abp.zig:5-6`, `src/filter/compiler.zig:129-131`.
|
||||
|
||||
### 9. Milestone 20's authority modes bound where rules are written
|
||||
|
||||
m20 classifies POST/PUT/DELETE `/api/rules` as `.config_write`
|
||||
(`src/web/routes.zig:94-97`), and under `.managed_file` authority the router
|
||||
answers 403 (`src/web/router.zig:173-178`). Everything in this milestone
|
||||
works in both modes, but the write path differs: in `.database` mode regex
|
||||
rules arrive through the API; in `.managed_file` mode they arrive through the
|
||||
config file and `src/config/reconcile.zig` (`reconcileRules`,
|
||||
reconcile.zig:566-598), whose enum comparison carries `.regex` with no code
|
||||
change. S3 owns a reconcile test proving a file-declared regex rule converges
|
||||
into the table. Every S3 API acceptance check runs a server in `.database`
|
||||
authority (no `--config`). The `SELECT *` dump helpers
|
||||
(`src/config/import.zig:167-186`, `src/config/reconcile.zig:981`) will emit
|
||||
the new `exception_count` column after `ddl_v3`; golden dump assertions in
|
||||
those suites are updated in S1, which owns that migration. Reconcile's
|
||||
runtime-column protection (reconcile.zig:11-15,435) covers `exception_count`
|
||||
with no change — the column survives reconciles untouched.
|
||||
|
||||
### 10. Fuzz invariants move, never lapse
|
||||
|
||||
`tests/fuzz/blocklist_fuzz.zig` header invariant "covers_apex only on
|
||||
`.wildcard`" (its stated form at :4-28) becomes "only on `.wildcard` or
|
||||
`.exception`". `tests/fuzz/compiler_fuzz.zig:43` reads `Format` from
|
||||
`compile`'s parameter list by index — the new `allow_w` parameter appends
|
||||
after `wild_w`, leaving index 2 valid; the session touching `compile` runs the
|
||||
fuzz suite and fixes that line if the assumption fails. A new
|
||||
`tests/fuzz/regex_fuzz.zig` target asserts: `compile` on arbitrary bytes
|
||||
never crashes and either errors or produces a program within the ruling-5
|
||||
limits; `matches` terminates and its VM step count never exceeds
|
||||
program length × (input length + 1); compile-then-match is deterministic.
|
||||
|
||||
## Sessions
|
||||
|
||||
Three sessions. S1 and S2 run in parallel — they share no files. S3 starts
|
||||
after both land.
|
||||
|
||||
### Session S1: list exceptions end to end (tier 1)
|
||||
|
||||
Owns: `src/filter/parsers.zig`, `src/filter/parser_abp.zig`,
|
||||
`src/filter/compiler.zig`, `src/filter/manager.zig`, `src/filter/matcher.zig`,
|
||||
`src/filter/domain_set.zig` (only if a helper is needed; expected untouched),
|
||||
`src/filter/filter_integration_test.zig`, `src/storage/migrations.zig`
|
||||
(step 3 only), `src/storage/repositories/sources_repo.zig`,
|
||||
`src/web/handlers/blocklists.zig`, `src/web/handlers/lookup.zig` (doc
|
||||
sentence only), `src/web/openapi.yaml` (StatusView shape only),
|
||||
`web/src/features/blocklists/*`, `web/src/lib/types.ts` (source-stat fields
|
||||
only), `web/src/lib/contractSamples.gen.ts`,
|
||||
`tests/fuzz/blocklist_fuzz.zig`, `tests/fuzz/compiler_fuzz.zig`, and the
|
||||
dump-golden assertions in `src/config/import.zig` and
|
||||
`src/config/reconcile.zig` test suites only (ruling 9's `exception_count`
|
||||
fallout from `ddl_v3`; S1 touches no reconcile logic).
|
||||
|
||||
- S1.1 `Kind.exception` in parsers.zig; parser_abp emits it per ruling 1;
|
||||
parser_hosts and parser_domains never emit it (no change beyond the enum).
|
||||
- S1.2 compiler third body per ruling 3; `Counts.exceptions`.
|
||||
- S1.3 manager: suffixes, header line, `bodyChecksum`, `loadSource`
|
||||
missing-file-is-empty, `Snapshot.Compiled.allow_body`,
|
||||
`prepareRefresh`/`publishRefresh`/`applyLoadOutcomes` carry the count.
|
||||
`rejectedWithoutEntries` (manager.zig:1563-1570) treats a compile with only
|
||||
exceptions as loadable, not rejected.
|
||||
- S1.4 matcher: `SourceSets.exceptions`, `Reason.blocklist_exception`,
|
||||
the new evaluate level per ruling 2, `memoryBytes` includes the new sets.
|
||||
- S1.5 storage + API + UI per ruling 4.
|
||||
- S1.6 tests: parser cases (`@@||x^`, `@@||x`, `@@||x^$important`,
|
||||
`@@||x^$third-party` → unsupported, `@@x` → unsupported); compiler
|
||||
three-body + checksum-compat cases (empty allow body reproduces the old
|
||||
digest byte for byte); manager header pin extended; matcher precedence
|
||||
cases: operator block beats list exception, list exception beats list
|
||||
domain and list wildcard, exception parent walk covers apex and
|
||||
subdomains; an integration case through
|
||||
`filter_integration_test.zig` with a real ABP fixture carrying `@@` lines.
|
||||
|
||||
Acceptance (S1):
|
||||
- [ ] `zig build test` passes; fuzz targets build and run.
|
||||
- [ ] A fixture list with `||ads.example^` and `@@||good.ads.example^`
|
||||
compiled and loaded blocks `ads.example` and `x.ads.example`, does not
|
||||
block `good.ads.example` or `y.good.ads.example`, and
|
||||
`/api/lookup` reports `blocklist_exception` with the source id for the
|
||||
latter two.
|
||||
- [ ] A pre-milestone data directory (no `.allow` files, old checksums)
|
||||
loads with zero checksum mismatches.
|
||||
- [ ] `POST /api/blocklists/update` response rows carry `exceptions`.
|
||||
|
||||
### Session S2: the regex engine (tier 2, engine only)
|
||||
|
||||
Owns: `src/filter/regex.zig` (new), `tests/fuzz/regex_fuzz.zig` (new),
|
||||
`src/tests.zig` (one added line), `build.zig` (fuzz-suite wiring for the new
|
||||
target only).
|
||||
|
||||
- S2.1 the engine per ruling 5: parser → AST → NFA program → Pike VM.
|
||||
- S2.2 unit tests in-file: every syntax form; anchored and unanchored
|
||||
matching; negated classes; `{n,m}` bounds; each error case; the
|
||||
pathological backtracker-killers (`(a+)+b` against `aaaaaaaaaaaaaaaaaaaaX`,
|
||||
nested alternation) complete within the step bound.
|
||||
- S2.3 the fuzz target per ruling 10.
|
||||
|
||||
Acceptance (S2):
|
||||
- [ ] `zig build test` passes with the new file in `src/tests.zig`.
|
||||
- [ ] The step-bound property holds under the fuzz corpus.
|
||||
- [ ] `regex.zig` imports nothing but `std`.
|
||||
|
||||
### Session S3: the regex rule kind, wired through (needs S1 + S2)
|
||||
|
||||
Owns: `src/storage/migrations.zig` (step 4), `src/storage/config_schema.zig`
|
||||
(comment only if needed), `src/config/model.zig`, `src/config/validate.zig`,
|
||||
`src/filter/rules.zig`, `src/filter/matcher.zig`,
|
||||
`src/storage/repositories/rules_repo.zig`, `src/web/handlers/rules.zig`,
|
||||
`src/web/handlers/mutations.zig` (only if `checkRule` needs the kind),
|
||||
`src/web/openapi.yaml`, `web/src/features/rules/*`, `web/src/lib/types.ts`,
|
||||
`web/src/lib/contractSamples.gen.ts`, `src/config/reconcile.zig` (test per
|
||||
ruling 9), `PLAN.md`, `src/filter/wildcard.zig` (comments),
|
||||
`src/filter/parsers.zig` (comment), `src/filter/parser_abp.zig` (comment),
|
||||
`src/filter/compiler.zig` (comment), `tools/bench.zig`.
|
||||
|
||||
- S3.1 migration step 4 per ruling 6; repo and model layers.
|
||||
- S3.2 validate at both edges (config import, API) per rulings 6 and 7.
|
||||
- S3.3 `RuleSet` six buckets; matcher levels per ruling 2; reasons.
|
||||
- S3.4 web + UI + openapi + samples per ruling 7.
|
||||
- S3.5 PLAN and comment amendments per ruling 8.
|
||||
- S3.6 bench: the filter suite in `tools/bench.zig` gains a variant with 32
|
||||
regex rules loaded; the existing p95 < 1 ms assertion covers it.
|
||||
- S3.7 tests: rule CRUD with kind `regex` through the API including the 400
|
||||
for a bad pattern at insert time; precedence cases regex-allow over
|
||||
regex-block, wildcard over regex, regex over list entries; export/import
|
||||
round trip; a migration test upgrading a v3 database.
|
||||
|
||||
Acceptance (S3):
|
||||
- [ ] `zig build test` and `cd web && npm test` pass.
|
||||
- [ ] `POST /api/rules` with `{"kind":"regex","pattern":"^ad[0-9]+-"}`
|
||||
returns 201; with `"pattern":"("` returns 400 naming the pattern.
|
||||
- [ ] A regex block rule blocks a matching name; `/api/lookup` reports
|
||||
`rule_block_regex` and `matched` carries the pattern text.
|
||||
- [ ] `zig build bench -Doptimize=ReleaseFast -- filter` passes its targets
|
||||
with the regex variant present.
|
||||
- [ ] PLAN §2.2 no longer forbids operator regex; all listed echoes updated.
|
||||
|
||||
### Orchestrator
|
||||
|
||||
Verify S1 and S2 acceptance before starting S3. After S3: run the full gate
|
||||
set (`zig build test`, `test-aarch64` if qemu present, `npm test`,
|
||||
`npm run assert-bundled`), then a live smoke against a scratch server: load
|
||||
one real ABP list with `@@` lines, add one regex rule, verify both over dig
|
||||
and `/api/lookup`. Record deviations in `## Recorded (implementation)`.
|
||||
|
||||
## Module layout
|
||||
|
||||
New files:
|
||||
- `src/filter/regex.zig` — the linear-time engine (ruling 5).
|
||||
- `tests/fuzz/regex_fuzz.zig` — its fuzz target (ruling 10).
|
||||
|
||||
Deleted surface: none.
|
||||
|
||||
## Acceptance (milestone complete)
|
||||
|
||||
- [ ] All session acceptance boxes above.
|
||||
- [ ] Schema at version 4; a v2 database migrates cleanly with data intact.
|
||||
- [ ] A pre-milestone blocklist data directory loads without refetch.
|
||||
- [ ] The six-level operator precedence plus three list levels behave per
|
||||
ruling 2, proven by matcher tests that enumerate adjacent-level pairs.
|
||||
- [ ] No `src/filter/` fuzz-root file imports anything but `std`.
|
||||
- [ ] Contract samples, openapi.yaml and `web/src/lib/types.ts` agree with
|
||||
the server (the drift guards pass).
|
||||
|
||||
## Anti-requirements
|
||||
|
||||
- No `$` modifier support beyond tolerating `$important` on exception lines.
|
||||
`$dnstype`, `$dnsrewrite`, `$client`, `$denyallow` and every browser
|
||||
modifier stay unsupported and counted.
|
||||
- No regex from downloaded lists. `.regex` lines stay counted and skipped;
|
||||
`skipped_regex` keeps its meaning. The engine exists for operator rules
|
||||
only.
|
||||
- No partial-segment wildcards (`ads*.example.com`) — writing one as a rule
|
||||
stays rejected; the regex kind covers the need.
|
||||
- No backreferences, lookaround, captures, named groups or Unicode classes in
|
||||
the engine, ever. A pattern needing them is rejected, not approximated.
|
||||
- No PCRE2, RE2 or any external regex dependency.
|
||||
- No exception-rule UI editor: exceptions come from lists; operators write
|
||||
allow rules.
|
||||
- No re-download forced by the upgrade; checksum compatibility (ruling 3) is
|
||||
a requirement, not an optimization.
|
||||
+443
-70
@@ -32,7 +32,6 @@ const tls = std.crypto.tls;
|
||||
|
||||
const api_limiter = @import("web/api_limiter.zig");
|
||||
const auth = @import("web/auth.zig");
|
||||
const bootstrap = @import("config/bootstrap.zig");
|
||||
const cert_store = @import("server/cert_store.zig");
|
||||
const cli = @import("cli.zig");
|
||||
const clients = @import("server/clients.zig");
|
||||
@@ -49,6 +48,7 @@ const fetcher = @import("filter/fetcher.zig");
|
||||
const forward_zones = @import("local/forward_zones.zig");
|
||||
const handler = @import("server/handler.zig");
|
||||
const http_util = @import("web/http_util.zig");
|
||||
const loader = @import("config/loader.zig");
|
||||
const local_records = @import("local/records.zig");
|
||||
const local_tables = @import("server/local_tables.zig");
|
||||
const logger_mod = @import("storage/logger.zig");
|
||||
@@ -60,6 +60,7 @@ const pause = @import("server/pause.zig");
|
||||
const pool_mod = @import("upstream/pool.zig");
|
||||
const query_sink = @import("server/query_sink.zig");
|
||||
const rate_limiter = @import("server/rate_limiter.zig");
|
||||
const reconcile = @import("config/reconcile.zig");
|
||||
const retention_mod = @import("storage/retention.zig");
|
||||
const safe_url = @import("safe_url.zig");
|
||||
const shutdown = @import("server/shutdown.zig");
|
||||
@@ -95,6 +96,12 @@ pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 {
|
||||
const mapped = failureExitCode(err);
|
||||
if (mapped == cli.exit_check) {
|
||||
runner.err.writeAll("run `nxdns check` to see the configuration in full\n") catch {};
|
||||
// Ruling 6: after bootstrap seeding died, a fresh database fails
|
||||
// validation naturally (`NoUsableUpstreams`) and the operator needs
|
||||
// to be told how a database gets a configuration at all. In file
|
||||
// mode they already have a file, and the diagnostics above name what
|
||||
// is wrong with it.
|
||||
if (args.config == null) cli.writeDbSourceHint(runner.err);
|
||||
}
|
||||
break :code mapped;
|
||||
};
|
||||
@@ -113,70 +120,201 @@ fn failureExitCode(err: anyerror) u8 {
|
||||
return if (faults.isConfigFault(err)) cli.exit_check else cli.exit_runtime;
|
||||
}
|
||||
|
||||
/// First run only: the file seeds an empty database and is ignored forever
|
||||
/// after. Its diagnostics are the operator's one chance to see what the file
|
||||
/// said, so they are printed the way `check` and `import` print them.
|
||||
/// File authority (ruling 2): read the file the operator named, validate it, and
|
||||
/// converge the database onto it — on this start and on every start after it.
|
||||
/// Returns the wall clock the reconcile ran at, which becomes the settings
|
||||
/// envelope's `reconciled_at`.
|
||||
///
|
||||
/// Printed on the way out either way. A seed file can be accepted and still
|
||||
/// carry warnings — a blocklist source in no group is downloaded and compiled
|
||||
/// into nothing — and a warning that only appears when the start fails is a
|
||||
/// warning nobody ever reads: the start it describes is the one that worked.
|
||||
/// `check` reported it and `run` did not, which left the same file graded two
|
||||
/// ways.
|
||||
/// A missing, unreadable or invalid file fails the start. There is no fallback
|
||||
/// to the database under any failure: a fallback turns a deploy typo into a
|
||||
/// silently stale configuration, which is the failure mode the whole mode exists
|
||||
/// to prevent.
|
||||
///
|
||||
/// The runner's error writer, not `std.log`: this runs before
|
||||
/// `logging.install`, and one rendering of a diagnostic across `run`, `check`
|
||||
/// and `import` is the point of `Diagnostics.writeAll`.
|
||||
/// Diagnostics are printed on the way out either way. A file can be accepted and
|
||||
/// still carry warnings — a blocklist source in no group is downloaded and
|
||||
/// compiled into nothing — and a warning that only appears when the start fails
|
||||
/// is a warning nobody ever reads: the start it describes is the one that
|
||||
/// worked.
|
||||
///
|
||||
/// Flushed here rather than left to `run`'s exit flush. That writer is buffered
|
||||
/// (`main` gives it 4 KiB) and `serve` does not return for as long as the
|
||||
/// service runs, so a line left in the buffer reaches the operator when the
|
||||
/// The runner's writers, not `std.log`: this runs before `logging.install`, and
|
||||
/// one rendering of a diagnostic across `run`, `check` and `import` is the point
|
||||
/// of `Diagnostics.writeAll`.
|
||||
///
|
||||
/// Flushed here rather than left to `run`'s exit flush. Both writers are
|
||||
/// buffered (`main` gives them 4 KiB) and `serve` does not return for as long as
|
||||
/// the service runs, so a line left in the buffer reaches the operator when the
|
||||
/// process stops — days after the start it describes. A failure path flushes
|
||||
/// anyway because it returns immediately; the successful start is the one that
|
||||
/// needs this.
|
||||
fn seedFromFile(
|
||||
fn reconcileFromFile(
|
||||
r: cli.Runner,
|
||||
config_db: *db.Db,
|
||||
dir: std.Io.Dir,
|
||||
config_path: []const u8,
|
||||
) bootstrap.Error!bootstrap.Outcome {
|
||||
) !i64 {
|
||||
return reconcileFromFileAt(
|
||||
r,
|
||||
config_db,
|
||||
dir,
|
||||
config_path,
|
||||
std.Io.Clock.real.now(r.io).toSeconds(),
|
||||
);
|
||||
}
|
||||
|
||||
/// `pass_now` is what the engine stamps into the runtime columns of the rows it
|
||||
/// inserts (`first_seen`, `last_seen`, `created_at`), and it is deliberately not
|
||||
/// the value this returns.
|
||||
///
|
||||
/// The two clocks answer different questions, and conflating them was a real
|
||||
/// defect: `reconciled_at` means "this process loaded the file at T" and is
|
||||
/// compared against the file's mtime to detect a restart-pending state
|
||||
/// (ruling 7). A stamp taken *before* the read makes a file written during the
|
||||
/// read look newer than the process that loaded it — a false "restart pending"
|
||||
/// in the UI for a file that is fully applied. So this returns a clock read
|
||||
/// taken immediately after the commit, and `pass_now` never leaves the engine.
|
||||
///
|
||||
/// Split from `reconcileFromFile` so the two are separable in a test: pin
|
||||
/// `pass_now` and the returned stamp must still be the real clock.
|
||||
fn reconcileFromFileAt(
|
||||
r: cli.Runner,
|
||||
config_db: *db.Db,
|
||||
dir: std.Io.Dir,
|
||||
config_path: []const u8,
|
||||
pass_now: i64,
|
||||
) !i64 {
|
||||
var arena_state: std.heap.ArenaAllocator = .init(r.gpa);
|
||||
defer arena_state.deinit();
|
||||
|
||||
var diags: validate.Diagnostics = .init(r.gpa);
|
||||
defer diags.deinit();
|
||||
|
||||
const result = bootstrap.bootstrap(r.io, r.gpa, config_db, dir, config_path, &diags);
|
||||
const result = applyManagedFile(r, config_db, dir, config_path, arena_state.allocator(), &diags, pass_now);
|
||||
|
||||
// Neither discard is an oversight, and the two answer different questions.
|
||||
//
|
||||
// On a rejected seed file, `result` is returned untouched: the operator gets
|
||||
// the reason the start failed, never a writer error standing in front of it.
|
||||
// A broken stderr is not why the configuration was refused.
|
||||
//
|
||||
// On a seed that worked, a failure here does not stop the start. The trade
|
||||
// is one lost warning line against a household with no name resolution, and
|
||||
// `run` before this point is the only stretch of this program where an
|
||||
// output failure could take DNS down at all — ruling 4 already says nothing
|
||||
// after it is fatal. Nor could the failure be reported: this writer *is* the
|
||||
// error channel, and `logging.install` has not run yet, so `std.log` resolves
|
||||
// to the same stderr a diagnostic about it would have to travel down.
|
||||
//
|
||||
// It is not lost from the process either. A failed drain consumes nothing,
|
||||
// so whatever the buffer held it still holds — that half is observed, in
|
||||
// "a broken error writer does not stop a first start that succeeded" below,
|
||||
// which reads the retained warning back out of the same writer.
|
||||
//
|
||||
// What happens to those bytes afterwards is derived, not watched, and is
|
||||
// labelled so deliberately. `Io.Writer.defaultFlush` drains while `end != 0`
|
||||
// and `run`'s exit flush maps a failure to exit 1, so a stderr still broken
|
||||
// at shutdown should carry the condition out in the exit code, and one that
|
||||
// recovered should deliver the line late. No test drives `run` that far.
|
||||
// Two limits come with the derivation: an empty buffer flushes clean and
|
||||
// reports nothing at all, and a failure that recovers ends at exit 0 with a
|
||||
// line the operator reads days after the start it describes.
|
||||
// Both discards are deliberate, and they answer different questions. On a
|
||||
// rejected file the operator gets the reason the start failed, never a
|
||||
// writer error standing in front of it — a broken stderr is not why the
|
||||
// configuration was refused. On a file that applied, a failure here does not
|
||||
// stop the start: the trade is one lost warning line against a household
|
||||
// with no name resolution, and this writer *is* the error channel, so the
|
||||
// failure has nowhere to be reported anyway.
|
||||
diags.writeAll(r.err) catch {};
|
||||
r.err.flush() catch {};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Returns the moment the transaction committed, which is what the settings
|
||||
/// envelope reports as `reconciled_at`.
|
||||
fn applyManagedFile(
|
||||
r: cli.Runner,
|
||||
config_db: *db.Db,
|
||||
dir: std.Io.Dir,
|
||||
config_path: []const u8,
|
||||
arena: Allocator,
|
||||
diags: *validate.Diagnostics,
|
||||
now: i64,
|
||||
) !i64 {
|
||||
const cfg = try loader.load(r.io, arena, dir, config_path, diags);
|
||||
try validate.validate(cfg, diags);
|
||||
|
||||
// The keys this pass wrote, never their values (ruling 8). Duplicated into
|
||||
// `gpa` by the engine, so this frame frees them.
|
||||
var changed: std.ArrayList([]const u8) = .empty;
|
||||
defer {
|
||||
for (changed.items) |key| r.gpa.free(key);
|
||||
changed.deinit(r.gpa);
|
||||
}
|
||||
|
||||
var pass = reconcile.begin(r.io, r.gpa, config_db, cfg, now, .{
|
||||
.changed_settings = &changed,
|
||||
}) catch |err| {
|
||||
// `begin` has already rolled its own transaction back. What the operator
|
||||
// needs is the cause: a full SD card must read as "disk", not as a bare
|
||||
// exit 1, so the SQLite condition is named.
|
||||
reportReconcileFailure(r, config_db, config_path, err);
|
||||
return err;
|
||||
};
|
||||
errdefer pass.rollback();
|
||||
|
||||
pass.commit() catch |err| {
|
||||
reportReconcileFailure(r, config_db, config_path, err);
|
||||
return err;
|
||||
};
|
||||
|
||||
// Read here and nowhere earlier: the file is loaded once this line runs, and
|
||||
// not one statement before it.
|
||||
const reconciled_at = std.Io.Clock.real.now(r.io).toSeconds();
|
||||
|
||||
printSummary(r, config_path, pass.summary, changed.items) catch {};
|
||||
return reconciled_at;
|
||||
}
|
||||
|
||||
/// SQLite conditions an operator acts on differently. `@errorName` alone would
|
||||
/// say `Full`, which is not a word anyone can search for; the primary result
|
||||
/// code's own name is.
|
||||
fn sqliteCodeName(err: anyerror) ?[]const u8 {
|
||||
return switch (err) {
|
||||
error.Full => "SQLITE_FULL",
|
||||
error.Busy => "SQLITE_BUSY",
|
||||
error.IoErr => "SQLITE_IOERR",
|
||||
error.ReadOnly => "SQLITE_READONLY",
|
||||
error.Corrupt => "SQLITE_CORRUPT",
|
||||
error.Constraint => "SQLITE_CONSTRAINT",
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
fn reportReconcileFailure(r: cli.Runner, config_db: *db.Db, config_path: []const u8, err: anyerror) void {
|
||||
var buf: [256]u8 = undefined;
|
||||
const detail = config_db.lastError(&buf);
|
||||
const code = sqliteCodeName(err) orelse @errorName(err);
|
||||
r.err.print("reconciling '{s}' failed: {s}: {s}\n", .{ config_path, code, detail }) catch {};
|
||||
r.err.flush() catch {};
|
||||
}
|
||||
|
||||
/// What that restart changed, without opening sqlite (ruling 8): per-table
|
||||
/// counts, the settings keys that moved — never their values — and an
|
||||
/// authentication change, which is never a silent line item in a count.
|
||||
fn printSummary(
|
||||
r: cli.Runner,
|
||||
config_path: []const u8,
|
||||
summary: reconcile.Summary,
|
||||
changed_settings: []const []const u8,
|
||||
) !void {
|
||||
try r.out.print("reconciled '{s}':", .{config_path});
|
||||
if (summary.isNoOp()) {
|
||||
try r.out.writeAll(" no changes\n");
|
||||
} else {
|
||||
inline for (@typeInfo(reconcile.Summary).@"struct".fields) |field| {
|
||||
if (field.type == reconcile.TableCounts) {
|
||||
const counts = @field(summary, field.name);
|
||||
if (counts.total() != 0) {
|
||||
try r.out.print(" {s} +{d} ~{d} -{d};", .{
|
||||
field.name,
|
||||
counts.inserted,
|
||||
counts.updated,
|
||||
counts.deleted,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
try r.out.writeAll("\n");
|
||||
|
||||
if (changed_settings.len != 0) {
|
||||
try r.out.writeAll("settings keys changed:");
|
||||
for (changed_settings) |key| try r.out.print(" {s}", .{key});
|
||||
try r.out.writeAll("\n");
|
||||
}
|
||||
switch (summary.auth_transition) {
|
||||
.none => {},
|
||||
.enabled => try r.out.writeAll("web authentication is now enabled\n"),
|
||||
.disabled => try r.out.writeAll("web authentication is now disabled\n"),
|
||||
.rotated => try r.out.writeAll("the web password changed\n"),
|
||||
}
|
||||
}
|
||||
try r.out.flush();
|
||||
}
|
||||
|
||||
fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
const io = r.io;
|
||||
const gpa = r.gpa;
|
||||
@@ -193,7 +331,15 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
defer config_db.close();
|
||||
_ = try migrations.migrate(&config_db);
|
||||
|
||||
_ = try seedFromFile(r, &config_db, std.Io.Dir.cwd(), paths.config);
|
||||
// Ruling 1: the presence of `--config` is the whole authority decision. With
|
||||
// it, the file is the sole declarative source and the database is converged
|
||||
// onto it here, before anything reads the database. Without it the database
|
||||
// is authority and this step does not exist — a file on disk that no flag
|
||||
// names changes nothing.
|
||||
const reconciled_at: ?i64 = if (args.config) |config_path|
|
||||
try reconcileFromFile(r, &config_db, std.Io.Dir.cwd(), config_path)
|
||||
else
|
||||
null;
|
||||
|
||||
// Every string in `cfg` points into this arena, and the pool's endpoints,
|
||||
// the handler's records and the monitor's paths all keep such strings. It
|
||||
@@ -203,6 +349,17 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
// The web layer reads the managed path out of `WebState` for the whole life
|
||||
// of the process, so it takes a copy from the arena that outlives it rather
|
||||
// than borrowing `argv`.
|
||||
const authority: web_server.Authority = if (args.config) |config_path|
|
||||
.{ .managed_file = try arena.dupe(u8, config_path) }
|
||||
else
|
||||
.database;
|
||||
|
||||
// The database stays the runtime substrate and the effective-config read
|
||||
// path in both modes: in file mode the reconcile above has just made it
|
||||
// agree with the file.
|
||||
const cfg = try config_export.readConfig(&config_db, arena);
|
||||
|
||||
// From here on `std.log` goes wherever the operator asked. Before this call
|
||||
@@ -472,7 +629,9 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
if (cfg.web.enabled) web_state = .{
|
||||
.gpa = gpa,
|
||||
.web = cfg.web,
|
||||
.live_hash = .init(cfg.web.password_hash),
|
||||
.authority = authority,
|
||||
.reconciled_at = reconciled_at,
|
||||
.live_hash = .init(cfg.web.password_hash orelse ""),
|
||||
.handler = &h,
|
||||
.pause = &paused,
|
||||
.tracker = &tracker,
|
||||
@@ -602,7 +761,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
// this box exists for — keeps serving.
|
||||
if (cfg.web.enabled) try group.concurrent(io, web_server.serve, .{ &web_state, io });
|
||||
|
||||
logStartup(io, &manager, upstreams.active().len, .{
|
||||
logStartup(io, authority, &manager, upstreams.active().len, .{
|
||||
.udp6 = if (udp6) |*s| s.boundAddress() else null,
|
||||
.udp4 = if (udp4) |*s| s.boundAddress() else null,
|
||||
.tcp6 = if (tcp6) |*s| s.boundAddress() else null,
|
||||
@@ -1005,8 +1164,8 @@ test "run, check and import agree on a seed file with no default group" {
|
||||
\\}
|
||||
;
|
||||
|
||||
// `run`: `serve` seeds through `bootstrap`, which is a wrapper over this
|
||||
// exact call, so this is the error `run` classifies.
|
||||
// `run --config`: `serve` validates through this exact call before it
|
||||
// reconciles, so this is the error `run` classifies.
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
defer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
@@ -1082,11 +1241,11 @@ test "a configuration whose blocklist source is in no group imports and checks c
|
||||
try std.testing.expectEqual(@as(usize, 1), check_diags.warningCount());
|
||||
}
|
||||
|
||||
test "a first start that seeds from a file prints the warnings the file earned" {
|
||||
// D5, second half. The seed file is read once in the life of a database, so
|
||||
// a warning it earns is printed on that start or never. `run` printed
|
||||
// diagnostics only when the file was rejected, which made a successful first
|
||||
// start the one place the finding could not surface.
|
||||
test "a start in file mode prints the warnings the file earned" {
|
||||
// D5, second half. `run` printed diagnostics only when the file was
|
||||
// rejected, which made a successful start the one place a finding could not
|
||||
// surface. Under file authority the file is read on every start, so this is
|
||||
// the line an operator sees after every restart, not only the first.
|
||||
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
@@ -1119,11 +1278,10 @@ test "a first start that seeds from a file prints the warnings the file earned"
|
||||
var err_writer = err_file.writer(io, &err_buf);
|
||||
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface };
|
||||
|
||||
// The file is valid, so the start succeeds and the database is seeded.
|
||||
try std.testing.expectEqual(
|
||||
bootstrap.Outcome.seeded,
|
||||
try seedFromFile(r, &database, tmp.dir, "config.zon"),
|
||||
);
|
||||
// The file is valid, so the start succeeds and the database converges onto
|
||||
// it. The returned stamp is what the settings envelope reports.
|
||||
const reconciled_at = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
|
||||
try std.testing.expect(reconciled_at > 0);
|
||||
try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
|
||||
|
||||
// Nothing flushes here on purpose. In production `serve` runs from this
|
||||
@@ -1138,6 +1296,164 @@ test "a first start that seeds from a file prints the warnings the file earned"
|
||||
try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "FAIL"));
|
||||
}
|
||||
|
||||
test "run with a missing managed file exits 2 with the path, and never serves from the database" {
|
||||
// Ruling 2: file mode fails closed. The database below is a perfectly good
|
||||
// one — migrated, and the run would have reached the listeners on it in db
|
||||
// mode — so a fallback would show up here as exit 0.
|
||||
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
const gpa = std.testing.allocator;
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
var data_buf: [160]u8 = undefined;
|
||||
const data_dir = try std.fmt.bufPrint(&data_buf, ".zig-cache/tmp/{s}/data", .{tmp.sub_path});
|
||||
var missing_buf: [160]u8 = undefined;
|
||||
const missing = try std.fmt.bufPrint(&missing_buf, ".zig-cache/tmp/{s}/nope.zon", .{tmp.sub_path});
|
||||
|
||||
// Real buffered `File.Writer`s, not `Writer.fixed`: this asserts the
|
||||
// operator received the lines, and a fixed writer's flush is a no-op that
|
||||
// counts a buffered line as delivered.
|
||||
var out_file = try tmp.dir.createFile(io, "stdout.txt", .{});
|
||||
defer out_file.close(io);
|
||||
var err_file = try tmp.dir.createFile(io, "stderr.txt", .{});
|
||||
defer err_file.close(io);
|
||||
var out_buf: [4096]u8 = undefined;
|
||||
var err_buf: [4096]u8 = undefined;
|
||||
var out_writer = out_file.writer(io, &out_buf);
|
||||
var err_writer = err_file.writer(io, &err_buf);
|
||||
const r: cli.Runner = .{
|
||||
.io = io,
|
||||
.gpa = gpa,
|
||||
.out = &out_writer.interface,
|
||||
.err = &err_writer.interface,
|
||||
};
|
||||
|
||||
try std.testing.expectEqual(cli.exit_check, run(r, .{
|
||||
.paths = .{ .data_dir = data_dir },
|
||||
.config = missing,
|
||||
}));
|
||||
|
||||
const printed = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192));
|
||||
defer gpa.free(printed);
|
||||
// The path is in the message: an error name alone tells the operator nothing
|
||||
// about which file the deploy got wrong.
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, missing));
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "no such file"));
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "nxdns check"));
|
||||
// The db-mode remediation hint belongs to db mode: in file mode the operator
|
||||
// has a file, and the diagnostic above says what is wrong with it.
|
||||
try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "make a file the source of truth"));
|
||||
}
|
||||
|
||||
test "reconciled_at is stamped after the commit, not from the clock the pass wrote with" {
|
||||
// Ruling 7: `reconciled_at` means "this process loaded the file at T", and
|
||||
// the UI compares it against the file's mtime to say whether a restart is
|
||||
// pending. A stamp taken before the read makes a file written while the read
|
||||
// ran look newer than the process that loaded it — a restart-pending banner
|
||||
// over a configuration that is fully applied.
|
||||
//
|
||||
// The pass clock is pinned to 1970 here, which the engine really does use:
|
||||
// the inserted client below carries it. If the two were one value, the
|
||||
// returned stamp would be 1970 too.
|
||||
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
const gpa = std.testing.allocator;
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
|
||||
\\.{
|
||||
\\ .groups = .{ .{ .name = "default" } },
|
||||
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
|
||||
\\ .clients = .{ .{ .ip = "192.168.1.5", .name = "tablet" } },
|
||||
\\}
|
||||
});
|
||||
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
defer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
_ = try migrations.migrate(&database);
|
||||
|
||||
var out_buf: [4096]u8 = undefined;
|
||||
var err_buf: [1024]u8 = undefined;
|
||||
var out: Writer = .fixed(&out_buf);
|
||||
var err: Writer = .fixed(&err_buf);
|
||||
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err };
|
||||
|
||||
const pass_now: i64 = 42;
|
||||
const before = std.Io.Clock.real.now(io).toSeconds();
|
||||
const reconciled_at = try reconcileFromFileAt(r, &database, tmp.dir, "config.zon", pass_now);
|
||||
|
||||
// The pinned clock reached the engine, so the two values really are separate
|
||||
// inputs rather than the same read twice.
|
||||
try std.testing.expectEqual(pass_now, try database.queryInt(
|
||||
"SELECT first_seen FROM clients WHERE ip = '192.168.1.5'",
|
||||
));
|
||||
try std.testing.expect(reconciled_at != pass_now);
|
||||
try std.testing.expect(reconciled_at >= before);
|
||||
}
|
||||
|
||||
test "the startup summary reports what the reconcile changed, then that nothing changed" {
|
||||
// Ruling 8: the answer to "what did that restart change" without opening
|
||||
// sqlite. Also ruling 5 from the operator's side — the second start of an
|
||||
// unchanged file says so.
|
||||
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
const gpa = std.testing.allocator;
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
|
||||
\\.{
|
||||
\\ .groups = .{ .{ .name = "default" } },
|
||||
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
|
||||
\\ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" },
|
||||
\\}
|
||||
});
|
||||
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
defer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
_ = try migrations.migrate(&database);
|
||||
|
||||
var out_file = try tmp.dir.createFile(io, "stdout.txt", .{});
|
||||
defer out_file.close(io);
|
||||
var out_buf: [4096]u8 = undefined;
|
||||
var out_writer = out_file.writer(io, &out_buf);
|
||||
var err_buf: [1024]u8 = undefined;
|
||||
var err: Writer = .fixed(&err_buf);
|
||||
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out_writer.interface, .err = &err };
|
||||
|
||||
_ = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
|
||||
{
|
||||
// Read back through the file: `serve` does not return for as long as the
|
||||
// service runs, so a summary still in the buffer is a summary nobody
|
||||
// reads.
|
||||
const printed = try tmp.dir.readFileAlloc(io, "stdout.txt", gpa, .limited(8192));
|
||||
defer gpa.free(printed);
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "reconciled 'config.zon':"));
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "upstreams +1 ~0 -0"));
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "settings keys changed:"));
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "web.password_hash"));
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "authentication is now enabled"));
|
||||
// The keys, never the values: the hash the file set must not be echoed.
|
||||
try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "$argon2id$"));
|
||||
}
|
||||
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "stdout.txt", .data = "" });
|
||||
_ = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
|
||||
{
|
||||
const printed = try tmp.dir.readFileAlloc(io, "stdout.txt", gpa, .limited(8192));
|
||||
defer gpa.free(printed);
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "no changes"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A broken stderr, in the shape `main` builds: a buffered `File.Writer`, with
|
||||
/// its drain switched to the mode that fails. `Writer.fixed` cannot stand in —
|
||||
/// its flush is `noopFlush`, so it has no failure to report and its `written()`
|
||||
@@ -1151,8 +1467,8 @@ fn brokenErrWriter(io: std.Io, file: std.Io.File, buffer: []u8) std.Io.File.Writ
|
||||
return w;
|
||||
}
|
||||
|
||||
test "a broken error writer does not replace the reason a seed file was rejected" {
|
||||
// The operator has to see why seeding failed, and a broken stderr is not
|
||||
test "a broken error writer does not replace the reason a managed file was rejected" {
|
||||
// The operator has to see why the start failed, and a broken stderr is not
|
||||
// that reason.
|
||||
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
@@ -1187,7 +1503,7 @@ test "a broken error writer does not replace the reason a seed file was rejected
|
||||
|
||||
try std.testing.expectError(
|
||||
error.MissingDefaultGroup,
|
||||
seedFromFile(r, &database, tmp.dir, "config.zon"),
|
||||
reconcileFromFile(r, &database, tmp.dir, "config.zon"),
|
||||
);
|
||||
|
||||
// Empty, so the writer did fail — without this the assertion above would
|
||||
@@ -1197,7 +1513,7 @@ test "a broken error writer does not replace the reason a seed file was rejected
|
||||
try std.testing.expectEqual(@as(usize, 0), printed.len);
|
||||
}
|
||||
|
||||
test "a broken error writer does not stop a first start that succeeded" {
|
||||
test "a broken error writer does not stop a start whose file applied" {
|
||||
// The call this file makes: a DNS server for a household does not refuse to
|
||||
// resolve because stderr is broken. What it must not do is drop the warning
|
||||
// on the floor, so the second half checks the buffer still holds it.
|
||||
@@ -1233,10 +1549,7 @@ test "a broken error writer does not stop a first start that succeeded" {
|
||||
var err_writer = brokenErrWriter(io, err_file, &err_buf);
|
||||
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface };
|
||||
|
||||
try std.testing.expectEqual(
|
||||
bootstrap.Outcome.seeded,
|
||||
try seedFromFile(r, &database, tmp.dir, "config.zon"),
|
||||
);
|
||||
_ = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
|
||||
try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
|
||||
|
||||
// Nothing reached the file, so the flush really did fail.
|
||||
@@ -1289,7 +1602,20 @@ fn isWildcard(addr: net.IpAddress) bool {
|
||||
/// One line, at info, naming what an operator needs to see in `journalctl`
|
||||
/// right after a restart: where it listens, how many upstreams it has, and
|
||||
/// whether filtering is live.
|
||||
fn logStartup(io: std.Io, manager: *manager_mod.Manager, upstream_count: usize, bound: Listeners) void {
|
||||
///
|
||||
/// Preceded by the authority (ruling 8), because every other question about a
|
||||
/// restart — why a UI edit vanished, why a file edit did not apply — starts with
|
||||
/// which of the two governs this process, and the journal is where an operator
|
||||
/// looks for it.
|
||||
fn logStartup(
|
||||
io: std.Io,
|
||||
authority: web_server.Authority,
|
||||
manager: *manager_mod.Manager,
|
||||
upstream_count: usize,
|
||||
bound: Listeners,
|
||||
) void {
|
||||
log.info("authority: {f}", .{AuthorityText{ .authority = authority }});
|
||||
|
||||
var buf: [256]u8 = undefined;
|
||||
var w: Writer = .fixed(&buf);
|
||||
appendBind(&w, "udp", bound.udp6);
|
||||
@@ -1316,6 +1642,25 @@ fn logStartup(io: std.Io, manager: *manager_mod.Manager, upstream_count: usize,
|
||||
}
|
||||
}
|
||||
|
||||
/// Which source governs this process, in the words the journal carries.
|
||||
///
|
||||
/// A formatter rather than a rendering into a buffer of this file's own: a
|
||||
/// managed path is bounded only by `Dir.max_path_bytes`, and nested bind mounts
|
||||
/// make long ones ordinary, so a fixed buffer here would silently drop exactly
|
||||
/// the half of the line an operator came for. Writing straight to the log sink's
|
||||
/// writer leaves the one documented, counted truncation in `platform/logging.zig`
|
||||
/// as the only limit.
|
||||
const AuthorityText = struct {
|
||||
authority: web_server.Authority,
|
||||
|
||||
pub fn format(self: AuthorityText, w: *Writer) Writer.Error!void {
|
||||
switch (self.authority) {
|
||||
.database => try w.writeAll("database"),
|
||||
.managed_file => |path| try w.print("file ({s})", .{path}),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Silent on overflow: a truncated startup line is not worth a failure path,
|
||||
/// and 256 bytes hold four addresses.
|
||||
fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void {
|
||||
@@ -1323,6 +1668,34 @@ fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void {
|
||||
w.print(" {s} {f}", .{ which, value }) catch {};
|
||||
}
|
||||
|
||||
test "the startup line names which source governs this process, path and all" {
|
||||
// Ruling 8. Every other question about a restart starts here, so the answer
|
||||
// is in the journal rather than derived from the unit file by whoever is
|
||||
// reading at 2am.
|
||||
const gpa = std.testing.allocator;
|
||||
|
||||
var short: Writer.Allocating = .init(gpa);
|
||||
defer short.deinit();
|
||||
try short.writer.print("{f}", .{AuthorityText{ .authority = .database }});
|
||||
try std.testing.expectEqualStrings("database", short.written());
|
||||
|
||||
var named: Writer.Allocating = .init(gpa);
|
||||
defer named.deinit();
|
||||
try named.writer.print("{f}", .{AuthorityText{ .authority = .{ .managed_file = "/etc/nxdns/config.zon" } }});
|
||||
try std.testing.expectEqualStrings("file (/etc/nxdns/config.zon)", named.written());
|
||||
|
||||
// A path past any buffer this file could reasonably have picked. Nested bind
|
||||
// mounts produce paths like this, and the path is the half of the line the
|
||||
// operator came for — dropping it to keep the line short is the wrong trade.
|
||||
const long_path = "/mnt/" ++ ("deeply-nested-mount/" ** 20) ++ "config.zon";
|
||||
try std.testing.expect(long_path.len > 256);
|
||||
|
||||
var long: Writer.Allocating = .init(gpa);
|
||||
defer long.deinit();
|
||||
try long.writer.print("{f}", .{AuthorityText{ .authority = .{ .managed_file = long_path } }});
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, long.written(), 1, long_path));
|
||||
}
|
||||
|
||||
const test_address = @import("platform/address.zig");
|
||||
|
||||
test "one maintenance pass drops the api limiter's stale buckets" {
|
||||
|
||||
+165
-106
@@ -22,6 +22,7 @@ const app = @import("app.zig");
|
||||
const config_export = @import("config/export.zig");
|
||||
const faults = @import("config/faults.zig");
|
||||
const import = @import("config/import.zig");
|
||||
const loader = @import("config/loader.zig");
|
||||
const model = @import("config/model.zig");
|
||||
const validate = @import("config/validate.zig");
|
||||
const cert_store = @import("server/cert_store.zig");
|
||||
@@ -50,19 +51,31 @@ pub const querylog_db_name = "querylog.db";
|
||||
pub const Paths = struct {
|
||||
/// PLAN §3.13.
|
||||
data_dir: []const u8 = "/var/lib/nxdns",
|
||||
config: []const u8 = "/etc/nxdns/config.zon",
|
||||
};
|
||||
|
||||
/// `config_explicit` records whether `--config` was given, because `check` has
|
||||
/// to tell "the operator named a file" from "the default path happens to
|
||||
/// exist".
|
||||
pub const CheckArgs = struct { paths: Paths = .{}, config_explicit: bool = false };
|
||||
/// Milestone-20 ruling 1: `--config` has no default path, and its presence is
|
||||
/// the whole of the authority decision. Null means the database is authority —
|
||||
/// for `run` that is today's appliance behaviour, for `check` it is the database
|
||||
/// that gets graded. A file sitting at a well-known path that no flag names
|
||||
/// changes nothing.
|
||||
pub const CheckArgs = struct { paths: Paths = .{}, config: ?[]const u8 = null };
|
||||
pub const ExportArgs = struct { paths: Paths = .{}, out: ?[]const u8 = null };
|
||||
pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, force: bool = false };
|
||||
|
||||
/// `allow_delete` is `--allow-delete`: it permits an import whose diff removes
|
||||
/// declarative rows (ruling 6).
|
||||
pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, allow_delete: bool = false };
|
||||
|
||||
/// `config` names the managed configuration file and, by being present at all,
|
||||
/// makes that file the sole declarative source of truth: it is read, validated
|
||||
/// and reconciled into the database on every start.
|
||||
///
|
||||
/// `web_dev` is milestone-8 ruling 24's `--web-dev <dir>`: serve the web
|
||||
/// interface from that directory instead of the embedded assets.
|
||||
pub const RunArgs = struct { paths: Paths = .{}, web_dev: ?[]const u8 = null };
|
||||
pub const RunArgs = struct {
|
||||
paths: Paths = .{},
|
||||
config: ?[]const u8 = null,
|
||||
web_dev: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
pub const Command = union(enum) {
|
||||
run: RunArgs,
|
||||
@@ -176,7 +189,7 @@ fn parseRunArgs(argv: []const []const u8) ParseError!RunArgs {
|
||||
if (eql(flag.name, "data-dir")) {
|
||||
args.paths.data_dir = try flagValue(flag, argv, &i);
|
||||
} else if (eql(flag.name, "config")) {
|
||||
args.paths.config = try flagValue(flag, argv, &i);
|
||||
args.config = try flagValue(flag, argv, &i);
|
||||
} else if (eql(flag.name, "web-dev")) {
|
||||
args.web_dev = try flagValue(flag, argv, &i);
|
||||
} else return error.UnknownFlag;
|
||||
@@ -192,8 +205,7 @@ fn parseCheckArgs(argv: []const []const u8) ParseError!CheckArgs {
|
||||
if (eql(flag.name, "data-dir")) {
|
||||
args.paths.data_dir = try flagValue(flag, argv, &i);
|
||||
} else if (eql(flag.name, "config")) {
|
||||
args.paths.config = try flagValue(flag, argv, &i);
|
||||
args.config_explicit = true;
|
||||
args.config = try flagValue(flag, argv, &i);
|
||||
} else return error.UnknownFlag;
|
||||
}
|
||||
return args;
|
||||
@@ -215,7 +227,7 @@ fn parseExportArgs(argv: []const []const u8) ParseError!ExportArgs {
|
||||
|
||||
fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs {
|
||||
var paths: Paths = .{};
|
||||
var force = false;
|
||||
var allow_delete = false;
|
||||
var file: ?[]const u8 = null;
|
||||
|
||||
var i: usize = 0;
|
||||
@@ -227,18 +239,18 @@ fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs {
|
||||
};
|
||||
if (eql(flag.name, "data-dir")) {
|
||||
paths.data_dir = try flagValue(flag, argv, &i);
|
||||
} else if (eql(flag.name, "force")) {
|
||||
// A boolean flag takes no value, so `--force=1` is not a spelling of
|
||||
// any flag this program has.
|
||||
} else if (eql(flag.name, "allow-delete")) {
|
||||
// A boolean flag takes no value, so `--allow-delete=1` is not a
|
||||
// spelling of any flag this program has.
|
||||
if (flag.attached != null) return error.UnknownFlag;
|
||||
force = true;
|
||||
allow_delete = true;
|
||||
} else return error.UnknownFlag;
|
||||
}
|
||||
|
||||
return .{
|
||||
.paths = paths,
|
||||
.file = file orelse return error.MissingArgument,
|
||||
.force = force,
|
||||
.allow_delete = allow_delete,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -379,14 +391,30 @@ const usage_text =
|
||||
\\
|
||||
\\options:
|
||||
\\ --data-dir DIR data directory (default /var/lib/nxdns)
|
||||
\\ --config FILE configuration file (default /etc/nxdns/config.zon)
|
||||
\\ --config FILE run: make FILE the sole source of configuration and
|
||||
\\ reconcile the database onto it at every start;
|
||||
\\ check: grade FILE instead of the database
|
||||
\\ --out FILE write the export to FILE instead of stdout
|
||||
\\ --force let import replace a database that already has content
|
||||
\\ --allow-delete let import apply a file whose diff deletes rows
|
||||
\\ --web-dev DIR run only: serve the web interface from DIR instead of
|
||||
\\ the embedded assets
|
||||
\\
|
||||
;
|
||||
|
||||
/// The one remediation line for a database that holds no usable configuration.
|
||||
/// Both routes to that state print it: `run` refusing an unconfigured database
|
||||
/// (`NoUsableUpstreams`) and `check` finding no `config.db` at all.
|
||||
///
|
||||
/// It lives here, beside the exit-code mapping, because a Zig error carries no
|
||||
/// text and `config/validate.zig` must stay blind to which source it is grading
|
||||
/// — the same file validates a database reading and a managed file.
|
||||
pub const db_source_hint =
|
||||
"load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`\n";
|
||||
|
||||
pub fn writeDbSourceHint(w: *Writer) void {
|
||||
w.writeAll(db_source_hint) catch {};
|
||||
}
|
||||
|
||||
/// Returns nothing, so a writer failure here has nowhere to go. Every caller
|
||||
/// flushes afterwards and reports that failure instead.
|
||||
pub fn usage(w: *Writer) void {
|
||||
@@ -493,7 +521,7 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
|
||||
&database,
|
||||
std.Io.Dir.cwd(),
|
||||
args.file,
|
||||
.{ .force = args.force },
|
||||
.{ .allow_delete = args.allow_delete },
|
||||
diags,
|
||||
);
|
||||
|
||||
@@ -514,9 +542,10 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
|
||||
/// That file is the only list — this function keeps none of its own, which is
|
||||
/// what stops `run`, `check` and `import` drifting apart again (D1).
|
||||
///
|
||||
/// `error.DatabaseNotEmpty` is the one exception, and it is deliberate: it
|
||||
/// reports the state of the database rather than the content of a file, so it
|
||||
/// is not a configuration fault, and `import` alone decides it is exit 2.
|
||||
/// `error.DestructiveImport` is the one exception, and it is deliberate: it
|
||||
/// reports what the diff would do to the database rather than the content of a
|
||||
/// file, so it is not a configuration fault, and `import` alone decides it is
|
||||
/// exit 2.
|
||||
///
|
||||
/// `error.OutOfMemory` is matched first, before anything else is consulted.
|
||||
/// Both recording paths — `validate` and import's per-line rendering of a ZON
|
||||
@@ -530,7 +559,7 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
|
||||
fn failureExitCode(e: anyerror, failures: usize) u8 {
|
||||
if (e == error.OutOfMemory) return exit_runtime;
|
||||
if (failures != 0) return exit_check;
|
||||
if (e == error.DatabaseNotEmpty) return exit_check;
|
||||
if (e == error.DestructiveImport) return exit_check;
|
||||
return if (faults.isConfigFault(e)) exit_check else exit_runtime;
|
||||
}
|
||||
|
||||
@@ -563,28 +592,31 @@ fn checkImpl(r: Runner, args: CheckArgs, probe: bool) !u8 {
|
||||
|
||||
// Which source was used is printed in every branch, so the answer is never
|
||||
// ambiguous about what it checked.
|
||||
if (args.config_explicit) {
|
||||
try r.out.print("checking configuration file {s}\n", .{args.paths.config});
|
||||
return checkFile(r, arena, args.paths.config, probe);
|
||||
//
|
||||
// Ruling 1: the invocation decides, and nothing else. The heuristic that
|
||||
// used to live here — probe for `config.db`, fall back to probing
|
||||
// `/etc/nxdns/config.zon`, grade whichever exists — made the answer a
|
||||
// function of what happened to be on disk, which is the ambient inference
|
||||
// that made seed-once bootstrap a source of documentation lies. A file no
|
||||
// flag names is not graded.
|
||||
if (args.config) |path| {
|
||||
try r.out.print("checking configuration file {s}\n", .{path});
|
||||
return checkFile(r, arena, path, probe);
|
||||
}
|
||||
|
||||
const config_db_path = try std.fs.path.joinZ(arena, &.{ args.paths.data_dir, config_db_name });
|
||||
if (try pathExists(r.io, config_db_path)) {
|
||||
try r.out.print("checking database {s}\n", .{config_db_path});
|
||||
return checkDatabase(r, arena, config_db_path, probe);
|
||||
if (!try pathExists(r.io, config_db_path)) {
|
||||
// The deleted heuristic's "nothing to check" branch, replaced rather
|
||||
// than dropped: an operator running `check` on a box that has never
|
||||
// been configured gets the same exit code and one line saying what to
|
||||
// do about it.
|
||||
try r.out.print("no config database at {s}\n", .{config_db_path});
|
||||
try r.out.writeAll(db_source_hint);
|
||||
return exit_check;
|
||||
}
|
||||
|
||||
if (try pathExists(r.io, args.paths.config)) {
|
||||
try r.out.print("checking configuration file {s}\n", .{args.paths.config});
|
||||
return checkFile(r, arena, args.paths.config, probe);
|
||||
}
|
||||
|
||||
try r.out.print("nothing to check: no {s} in {s} and no {s}\n", .{
|
||||
config_db_name,
|
||||
args.paths.data_dir,
|
||||
args.paths.config,
|
||||
});
|
||||
return exit_check;
|
||||
try r.out.print("checking database {s}\n", .{config_db_path});
|
||||
return checkDatabase(r, arena, config_db_path, probe);
|
||||
}
|
||||
|
||||
/// `check` reads `config.db` and writes nothing to it (F-c): no create, no
|
||||
@@ -722,52 +754,31 @@ fn pathReadable(io: std.Io, path: []const u8) std.Io.Dir.AccessError!bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Grades the file `--config` named, through `config/loader.zig` — the same
|
||||
/// read and the same classification `nxdns run --config` uses. That shared
|
||||
/// helper is what makes the scoped agreement of ruling 2 true: `check` reaches
|
||||
/// exactly the read, size and parse faults `run` would reach, and validation
|
||||
/// below is the same call on the same `Config`.
|
||||
///
|
||||
/// D4: a named file that is missing or unreadable is the same operator-fixable
|
||||
/// condition as one that fails to parse, so it is reported as a finding rather
|
||||
/// than escaping as a runtime failure.
|
||||
fn checkFile(r: Runner, arena: Allocator, path: []const u8, probe: bool) !u8 {
|
||||
const source = std.Io.Dir.cwd().readFileAllocOptions(
|
||||
r.io,
|
||||
path,
|
||||
arena,
|
||||
.limited(import.max_config_bytes),
|
||||
.of(u8),
|
||||
0,
|
||||
) catch |e| switch (e) {
|
||||
error.StreamTooLong => {
|
||||
try r.out.print("FAIL {s}: larger than {d} bytes\n", .{ path, import.max_config_bytes });
|
||||
return exit_check;
|
||||
},
|
||||
// D4: a named file that is missing or unreadable is the same
|
||||
// operator-fixable condition as one that fails to parse, so it is
|
||||
// reported as a finding rather than escaping as a runtime failure. The
|
||||
// implicit path already exits 2 when it finds nothing to check; naming
|
||||
// the file must not change the code.
|
||||
error.FileNotFound => {
|
||||
try r.out.print("FAIL {s}: no such file\n", .{path});
|
||||
return exit_check;
|
||||
},
|
||||
error.AccessDenied, error.PermissionDenied => {
|
||||
try r.out.print("FAIL {s}: not readable\n", .{path});
|
||||
return exit_check;
|
||||
},
|
||||
else => |other| return other,
|
||||
};
|
||||
var diags: validate.Diagnostics = .init(r.gpa);
|
||||
defer diags.deinit();
|
||||
|
||||
// Arena-owned and never handed to `std.zon.parse.free`; see the rule and its
|
||||
// `parse.zig:874` citation in `config/import.zig`.
|
||||
var zon_diag: std.zon.parse.Diagnostics = .{};
|
||||
const cfg = std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) {
|
||||
const cfg = loader.load(r.io, arena, std.Io.Dir.cwd(), path, &diags) catch |e| switch (e) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
// The rendering carries the line and column, which is the whole value of
|
||||
// running `check` against a file the operator just edited. It is
|
||||
// multi-line, and `check` promises one line per problem, so it goes
|
||||
// through the same `Diagnostics` channel `nxdns import` uses rather than
|
||||
// into one `FAIL` record with newlines inside it.
|
||||
error.ParseZon => {
|
||||
var diags: validate.Diagnostics = .init(r.gpa);
|
||||
defer diags.deinit();
|
||||
try import.reportParseFailure(&diags, &zon_diag);
|
||||
error.ManagedConfigUnreadable, error.ConfigTooLarge, error.ParseZon => {
|
||||
// One line per problem, the promise the rest of `check` keeps: a
|
||||
// multi-line ZON rendering is several problems, not one `FAIL`
|
||||
// record with newlines inside it.
|
||||
try diags.writeAll(r.out);
|
||||
return exit_check;
|
||||
},
|
||||
// A box fault — fd exhaustion, an I/O error — is not a verdict on the
|
||||
// configuration and keeps its own name at exit 1.
|
||||
else => |other| return other,
|
||||
};
|
||||
|
||||
return checkConfig(r, cfg, probe);
|
||||
@@ -1013,17 +1024,22 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "parseArgs accepts run with no flags" {
|
||||
test "run without --config selects database authority" {
|
||||
// Ruling 1: authority is the invocation. No default path, so nothing on
|
||||
// disk can make a bare `run` read a file.
|
||||
const command = try parseArgs(&.{"run"});
|
||||
try testing.expectEqualStrings("/var/lib/nxdns", command.run.paths.data_dir);
|
||||
try testing.expectEqualStrings("/etc/nxdns/config.zon", command.run.paths.config);
|
||||
try testing.expectEqual(@as(?[]const u8, null), command.run.config);
|
||||
try testing.expectEqual(@as(?[]const u8, null), command.run.web_dev);
|
||||
}
|
||||
|
||||
test "parseArgs accepts run with --data-dir and --config" {
|
||||
test "run --config selects file authority and the path lands in the run args" {
|
||||
const attached = try parseArgs(&.{ "run", "--config=/etc/nxdns/config.zon" });
|
||||
try testing.expectEqualStrings("/etc/nxdns/config.zon", attached.run.config.?);
|
||||
|
||||
const command = try parseArgs(&.{ "run", "--data-dir", "/srv/nx", "--config", "/tmp/c.zon" });
|
||||
try testing.expectEqualStrings("/srv/nx", command.run.paths.data_dir);
|
||||
try testing.expectEqualStrings("/tmp/c.zon", command.run.paths.config);
|
||||
try testing.expectEqualStrings("/tmp/c.zon", command.run.config.?);
|
||||
}
|
||||
|
||||
test "parseArgs accepts run with --web-dev in both spellings" {
|
||||
@@ -1049,13 +1065,12 @@ test "parseArgs accepts --data-dir with and without an equals sign" {
|
||||
try testing.expectEqualStrings("/srv/nx", separate.check.paths.data_dir);
|
||||
}
|
||||
|
||||
test "parseArgs records whether check was given an explicit --config" {
|
||||
test "bare check grades the database and check --config grades the file" {
|
||||
const implicit = try parseArgs(&.{"check"});
|
||||
try testing.expect(!implicit.check.config_explicit);
|
||||
try testing.expectEqual(@as(?[]const u8, null), implicit.check.config);
|
||||
|
||||
const explicit = try parseArgs(&.{ "check", "--config=/tmp/c.zon" });
|
||||
try testing.expect(explicit.check.config_explicit);
|
||||
try testing.expectEqualStrings("/tmp/c.zon", explicit.check.paths.config);
|
||||
try testing.expectEqualStrings("/tmp/c.zon", explicit.check.config.?);
|
||||
}
|
||||
|
||||
test "parseArgs accepts export with --out" {
|
||||
@@ -1066,17 +1081,21 @@ test "parseArgs accepts export with --out" {
|
||||
try testing.expectEqual(@as(?[]const u8, null), bare.export_.out);
|
||||
}
|
||||
|
||||
test "parseArgs accepts import with a file, --force and --data-dir" {
|
||||
const command = try parseArgs(&.{ "import", "c.zon", "--force", "--data-dir=/srv/nx" });
|
||||
test "parseArgs accepts import with a file, --allow-delete and --data-dir" {
|
||||
const command = try parseArgs(&.{ "import", "c.zon", "--allow-delete", "--data-dir=/srv/nx" });
|
||||
try testing.expectEqualStrings("c.zon", command.import_.file);
|
||||
try testing.expect(command.import_.force);
|
||||
try testing.expect(command.import_.allow_delete);
|
||||
try testing.expectEqualStrings("/srv/nx", command.import_.paths.data_dir);
|
||||
}
|
||||
|
||||
test "parseArgs accepts import with the file after the flags" {
|
||||
const command = try parseArgs(&.{ "import", "--data-dir", "/srv/nx", "c.zon" });
|
||||
try testing.expectEqualStrings("c.zon", command.import_.file);
|
||||
try testing.expect(!command.import_.force);
|
||||
try testing.expect(!command.import_.allow_delete);
|
||||
}
|
||||
|
||||
test "the renamed import flag replaces --force rather than joining it" {
|
||||
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--force" }));
|
||||
}
|
||||
|
||||
test "parseArgs accepts version" {
|
||||
@@ -1091,7 +1110,7 @@ test "parseArgs accepts help, --help and -h" {
|
||||
|
||||
test "parseArgs rejects import without a file" {
|
||||
try testing.expectError(error.MissingArgument, parseArgs(&.{"import"}));
|
||||
try testing.expectError(error.MissingArgument, parseArgs(&.{ "import", "--force" }));
|
||||
try testing.expectError(error.MissingArgument, parseArgs(&.{ "import", "--allow-delete" }));
|
||||
}
|
||||
|
||||
test "parseArgs rejects --out without a value" {
|
||||
@@ -1101,7 +1120,7 @@ test "parseArgs rejects --out without a value" {
|
||||
|
||||
test "parseArgs rejects an unknown flag" {
|
||||
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "check", "--nope" }));
|
||||
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--force=1" }));
|
||||
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--allow-delete=1" }));
|
||||
}
|
||||
|
||||
test "parseArgs rejects an unknown command" {
|
||||
@@ -1134,6 +1153,17 @@ test "usage_text lists every command in command_names" {
|
||||
}
|
||||
}
|
||||
|
||||
test "usage_text names the flags this milestone renamed and describes --config" {
|
||||
// The flag an operator reaches for is the one the help text names. `--force`
|
||||
// is gone rather than aliased (greenfield rules), and `--config` no longer
|
||||
// advertises a default path, because there is none: its presence is the
|
||||
// whole authority decision.
|
||||
try testing.expect(std.mem.containsAtLeast(u8, usage_text, 1, " --allow-delete "));
|
||||
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, usage_text, "--force"));
|
||||
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, usage_text, "default /etc/nxdns/config.zon"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, usage_text, 1, "sole source of configuration"));
|
||||
}
|
||||
|
||||
test "usage writes non-empty text" {
|
||||
var out: Writer.Allocating = .init(testing.allocator);
|
||||
defer out.deinit();
|
||||
@@ -1246,7 +1276,7 @@ test "runUsageError names the fault and prints the usage text" {
|
||||
}
|
||||
|
||||
test "failureExitCode separates a fixable configuration from a runtime failure" {
|
||||
try testing.expectEqual(exit_check, failureExitCode(error.DatabaseNotEmpty, 0));
|
||||
try testing.expectEqual(exit_check, failureExitCode(error.DestructiveImport, 0));
|
||||
try testing.expectEqual(exit_check, failureExitCode(error.ParseZon, 0));
|
||||
try testing.expectEqual(exit_check, failureExitCode(error.NoUpstreams, 1));
|
||||
try testing.expectEqual(exit_runtime, failureExitCode(error.IoErr, 0));
|
||||
@@ -1302,9 +1332,13 @@ test "failureExitCode keeps no list of its own and classifies through config/fau
|
||||
}
|
||||
|
||||
// The one config-shaped exit 2 `cli` still decides for itself: it reports
|
||||
// the state of the database, not the content of a file.
|
||||
try testing.expect(!faults.isConfigFault(error.DatabaseNotEmpty));
|
||||
try testing.expectEqual(exit_check, failureExitCode(error.DatabaseNotEmpty, 0));
|
||||
// what the diff would do to the database, not the content of a file.
|
||||
try testing.expect(!faults.isConfigFault(error.DestructiveImport));
|
||||
try testing.expectEqual(exit_check, failureExitCode(error.DestructiveImport, 0));
|
||||
|
||||
// The managed file goes the other way: `config/loader.zig` converts the
|
||||
// path class, so the classification — not this function — carries it.
|
||||
try testing.expectEqual(exit_check, failureExitCode(error.ManagedConfigUnreadable, 0));
|
||||
}
|
||||
|
||||
const fixtures = @import("test_fixtures");
|
||||
@@ -1457,10 +1491,7 @@ test "check --config naming a missing file is a reported failure at exit 2" {
|
||||
defer captured.deinit();
|
||||
const r = captured.runner();
|
||||
|
||||
const code = runCheck(r, .{
|
||||
.paths = .{ .config = env.missing_path },
|
||||
.config_explicit = true,
|
||||
}, false);
|
||||
const code = runCheck(r, .{ .config = env.missing_path }, false);
|
||||
|
||||
try testing.expectEqual(exit_check, code);
|
||||
const text = captured.out.written();
|
||||
@@ -1469,6 +1500,37 @@ test "check --config naming a missing file is a reported failure at exit 2" {
|
||||
try testing.expectEqualStrings("", captured.err.written());
|
||||
}
|
||||
|
||||
test "bare check with no config database exits 2 and says how to make one" {
|
||||
// The deleted heuristic's "nothing to check" branch, replaced. The valid
|
||||
// file sitting in the same directory is the other half of ruling 1: bare
|
||||
// `check` grades the database, and a file no flag named is not consulted —
|
||||
// if it were, this run would print "OK: no problems found" instead.
|
||||
var env: CheckEnv = undefined;
|
||||
try env.init();
|
||||
defer env.deinit();
|
||||
|
||||
var captured: Captured = .init(testing.allocator);
|
||||
defer captured.deinit();
|
||||
const r = captured.runner();
|
||||
|
||||
try env.tmp.dir.writeFile(r.io, .{ .sub_path = "config.zon", .data =
|
||||
\\.{
|
||||
\\ .groups = .{ .{ .name = "default" } },
|
||||
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
|
||||
\\}
|
||||
});
|
||||
|
||||
const code = runCheck(r, .{ .paths = .{ .data_dir = env.data_dir } }, false);
|
||||
|
||||
try testing.expectEqual(exit_check, code);
|
||||
const text = captured.out.written();
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "no config database at "));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, config_db_name));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, db_source_hint));
|
||||
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, text, "OK"));
|
||||
try testing.expectEqualStrings("", captured.err.written());
|
||||
}
|
||||
|
||||
test "check renders a multi-line ZON failure as one FAIL line per message" {
|
||||
// The rendering used to go inline into a single `FAIL` record, which put
|
||||
// newlines mid-line and broke the one-line-per-problem promise the rest of
|
||||
@@ -1486,10 +1548,7 @@ test "check renders a multi-line ZON failure as one FAIL line per message" {
|
||||
var path_buf: [160]u8 = undefined;
|
||||
const config_path = try env.path(&path_buf, "config.zon");
|
||||
|
||||
const code = runCheck(r, .{
|
||||
.paths = .{ .config = config_path },
|
||||
.config_explicit = true,
|
||||
}, false);
|
||||
const code = runCheck(r, .{ .config = config_path }, false);
|
||||
try testing.expectEqual(exit_check, code);
|
||||
|
||||
const text = captured.out.written();
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
//! First-start seeding (PLAN §3.5).
|
||||
//!
|
||||
//! A policy wrapper over `import.importFile`, and nothing more. There is exactly
|
||||
//! one code path from a config file into the database, so bootstrap and
|
||||
//! `nxdns import` cannot drift apart.
|
||||
//!
|
||||
//! The policy is three lines long:
|
||||
//!
|
||||
//! - no file → normal steady state, keep the database as it is;
|
||||
//! - database already configured → the file is ignored, as PLAN §3.5 requires.
|
||||
//! Configured means an operator put something there. A database that has only
|
||||
//! answered queries is not configured, however many client rows the DNS path
|
||||
//! materialised into it, and `import.isEmpty` is where that line is drawn;
|
||||
//! - otherwise → import it, and a file that is unreadable, unparseable or
|
||||
//! invalid is an error. The operator wrote that file and meant it; starting
|
||||
//! with silent defaults instead is the exact failure mode PLAN §1.3 exists to
|
||||
//! prevent.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const db = @import("../storage/db.zig");
|
||||
const import = @import("import.zig");
|
||||
const validate = @import("validate.zig");
|
||||
|
||||
const log = std.log.scoped(.config_bootstrap);
|
||||
|
||||
pub const Outcome = enum { seeded, db_already_configured, no_config_file };
|
||||
|
||||
pub const Error = import.Error || std.Io.Dir.AccessError;
|
||||
|
||||
/// Called by `nxdns run` before serving.
|
||||
pub fn bootstrap(
|
||||
io: std.Io,
|
||||
gpa: Allocator,
|
||||
database: *db.Db,
|
||||
dir: std.Io.Dir,
|
||||
config_path: []const u8,
|
||||
diags: *validate.Diagnostics,
|
||||
) Error!Outcome {
|
||||
dir.access(io, config_path, .{}) catch |e| switch (e) {
|
||||
error.FileNotFound => {
|
||||
log.info("no configuration file at '{s}'; using the database as it is", .{config_path});
|
||||
return .no_config_file;
|
||||
},
|
||||
else => |other| return other,
|
||||
};
|
||||
|
||||
// Deliberately before the read: on every start after the first, the file is
|
||||
// not even opened.
|
||||
if (!try import.isEmpty(database)) {
|
||||
log.info("configuration file ignored; the database is already configured", .{});
|
||||
return .db_already_configured;
|
||||
}
|
||||
|
||||
try import.importFile(io, gpa, database, dir, config_path, .{ .force = false }, diags);
|
||||
log.info("seeded the database from '{s}'", .{config_path});
|
||||
return .seeded;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// All three outcomes are exercised end to end in
|
||||
// `src/storage/storage_integration_test.zig` (S7) against a real data directory.
|
||||
// What the two cases below add is the one distinction that decides which outcome
|
||||
// an operator gets, and it is too important to leave behind a `-Dintegration`
|
||||
// flag: whether the database has been *configured*, not whether it has been
|
||||
// *used*.
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
const clients_repo = @import("../storage/repositories/clients_repo.zig");
|
||||
const migrations = @import("../storage/migrations.zig");
|
||||
|
||||
const seed_source =
|
||||
\\.{
|
||||
\\ .groups = .{ .{ .name = "default" } },
|
||||
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
|
||||
\\}
|
||||
;
|
||||
|
||||
/// Unparseable on purpose: a call that succeeds proves the file was never read.
|
||||
const broken_source = ".{ .groups = ";
|
||||
|
||||
fn openMigrated() !db.Db {
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
errdefer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
_ = try migrations.migrate(&database);
|
||||
return database;
|
||||
}
|
||||
|
||||
test "a server that has answered queries still seeds from its configuration file" {
|
||||
const io = testing.io;
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = seed_source });
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
// The unattended first boot: the server came up on defaults, answered
|
||||
// traffic, and the operator dropped a config file in afterwards.
|
||||
try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);
|
||||
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
|
||||
const outcome = try bootstrap(io, testing.allocator, &database, tmp.dir, "config.zon", &diags);
|
||||
try testing.expectEqual(Outcome.seeded, outcome);
|
||||
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
|
||||
// Seeding did not cost the operator the device list they had been watching.
|
||||
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
|
||||
}
|
||||
|
||||
test "a client the operator has customised keeps the configuration file out" {
|
||||
const io = testing.io;
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = broken_source });
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);
|
||||
const id = try database.queryInt("SELECT id FROM clients WHERE ip = '192.168.1.5'");
|
||||
try clients_repo.updateClient(&database, id, .{ .name = "tv", .group_id = 1 });
|
||||
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
|
||||
const outcome = try bootstrap(io, testing.allocator, &database, tmp.dir, "config.zon", &diags);
|
||||
try testing.expectEqual(Outcome.db_already_configured, outcome);
|
||||
try testing.expectEqual(@as(usize, 0), diags.problems.items.len);
|
||||
// The name and the flag the operator set are still theirs.
|
||||
try testing.expectEqual(@as(i64, 1), try database.queryInt(
|
||||
"SELECT count(*) FROM clients WHERE name = 'tv' AND hand_edited = 1",
|
||||
));
|
||||
}
|
||||
+57
-10
@@ -34,7 +34,7 @@ pub const Error = ReadError || Writer.Error ||
|
||||
|
||||
const header =
|
||||
\\// nxdns configuration
|
||||
\\// generated by `nxdns export` — the database is the source of truth
|
||||
\\// generated by `nxdns export` from the running configuration
|
||||
\\
|
||||
;
|
||||
|
||||
@@ -64,11 +64,13 @@ pub fn readConfig(database: *db.Db, arena: Allocator) ReadError!model.Config {
|
||||
cfg.local_records = (try local_repo.listLocalRecords(database, arena)).items;
|
||||
cfg.forward_zones = (try local_repo.listForwardZones(database, arena)).items;
|
||||
|
||||
// `web.password` is operator input and is never stored; the exported file
|
||||
// always carries an empty one. This is exactly what makes the round trip
|
||||
// stable: re-importing takes the "password is empty" branch and stores the
|
||||
// same hash.
|
||||
cfg.web.password = "";
|
||||
// `web.password` is operator input and is never stored, so the exported
|
||||
// file always states it as absent. Absent rather than `""`: a present empty
|
||||
// password is refused by `validate` (ruling 4), so exporting one would make
|
||||
// every export fail its own rules. It is also what makes the round trip
|
||||
// stable — re-applying the file takes the "password_hash written verbatim"
|
||||
// branch and stores the same hash.
|
||||
cfg.web.password = null;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
@@ -250,7 +252,7 @@ test "readConfig, writeConfig, import and readConfig again produce an equal conf
|
||||
|
||||
try testing.expectEqual(a.dns.port, b.dns.port);
|
||||
try testing.expectEqual(a.logging.level, b.logging.level);
|
||||
try testing.expectEqualStrings(a.web.password_hash, b.web.password_hash);
|
||||
try testing.expectEqualStrings(a.web.password_hash.?, b.web.password_hash.?);
|
||||
try testing.expectEqual(a.groups.len, b.groups.len);
|
||||
try testing.expectEqual(a.upstreams.len, b.upstreams.len);
|
||||
for (a.upstreams, b.upstreams) |left, right| {
|
||||
@@ -303,12 +305,57 @@ test "an exported password_hash survives a re-import unchanged" {
|
||||
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
|
||||
.web = .{ .password = "correct horse battery staple" },
|
||||
};
|
||||
try import.applyToDb(io, gpa, &database, cfg, 42, .{});
|
||||
var diags: validate.Diagnostics = .init(gpa);
|
||||
defer diags.deinit();
|
||||
try import.apply(io, gpa, &database, cfg, 42, .{}, &diags);
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena_state.deinit();
|
||||
const exported = try readConfig(&database, arena_state.allocator());
|
||||
|
||||
try testing.expectEqualStrings("", exported.web.password);
|
||||
try testing.expect(std.mem.startsWith(u8, exported.web.password_hash, "$argon2id$"));
|
||||
try testing.expectEqual(@as(?[]const u8, null), exported.web.password);
|
||||
try testing.expect(std.mem.startsWith(u8, exported.web.password_hash.?, "$argon2id$"));
|
||||
}
|
||||
|
||||
test "the exported password form is the one validate accepts" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
const gpa = testing.allocator;
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
var apply_diags: validate.Diagnostics = .init(gpa);
|
||||
defer apply_diags.deinit();
|
||||
try import.apply(io, gpa, &database, .{
|
||||
.groups = &.{.{ .name = "default" }},
|
||||
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
|
||||
.web = .{ .password = "correct horse battery staple" },
|
||||
}, 42, .{}, &apply_diags);
|
||||
|
||||
var out: Writer.Allocating = .init(gpa);
|
||||
defer out.deinit();
|
||||
try writeToWriter(gpa, &database, &out.writer);
|
||||
|
||||
// The literal form matters: an export carrying `password = ""` beside a
|
||||
// stored hash would trip `EmptyWebPassword` on the way back in, so export
|
||||
// would produce a file its own validator refuses.
|
||||
try testing.expect(std.mem.indexOf(u8, out.written(), ".password = null,") != null);
|
||||
|
||||
const source = try gpa.dupeZ(u8, out.written());
|
||||
defer gpa.free(source);
|
||||
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena_state.deinit();
|
||||
const reparsed = try std.zon.parse.fromSliceAlloc(
|
||||
model.Config,
|
||||
arena_state.allocator(),
|
||||
source,
|
||||
null,
|
||||
.{},
|
||||
);
|
||||
var diags: validate.Diagnostics = .init(gpa);
|
||||
defer diags.deinit();
|
||||
try validate.validate(reparsed, &diags);
|
||||
try testing.expectEqual(@as(?[]const u8, null), reparsed.web.password);
|
||||
try testing.expect(std.mem.startsWith(u8, reparsed.web.password_hash.?, "$argon2id$"));
|
||||
}
|
||||
|
||||
+27
-10
@@ -13,17 +13,26 @@ const validate = @import("validate.zig");
|
||||
|
||||
/// `ValidateError` enters as a whole set rather than variant by variant, so a
|
||||
/// variant added to the validator cannot silently fall through to exit 1. The
|
||||
/// four extras are the configuration faults raised outside the validator: the
|
||||
/// ZON reader (`ParseZon`), the seed-file size limit (`ConfigTooLarge`), the
|
||||
/// composition root's upstream build (`NoUsableUpstreams`) and its certificate
|
||||
/// load (`BadCertificate`).
|
||||
/// five extras are the configuration faults raised outside the validator: the
|
||||
/// ZON reader (`ParseZon`), the file size limit (`ConfigTooLarge`), the managed
|
||||
/// file the operator named and this process cannot open
|
||||
/// (`ManagedConfigUnreadable`, milestone-20 ruling 2), the composition root's
|
||||
/// upstream build (`NoUsableUpstreams`) and its certificate load
|
||||
/// (`BadCertificate`).
|
||||
///
|
||||
/// Not here on purpose: `error.DatabaseNotEmpty`, which reports the state of
|
||||
/// the database rather than the content of a file, and is the one config-shaped
|
||||
/// exit 2 `cli` decides for itself.
|
||||
/// `ManagedConfigUnreadable` is the only place a missing or unreadable path is a
|
||||
/// configuration fault, and it is deliberately not `FileNotFound` itself: the
|
||||
/// operator named that path on the command line, so it is theirs to fix, while a
|
||||
/// missing file anywhere else stays a runtime failure. `config/loader.zig` owns
|
||||
/// the conversion and the closed set of open errors that qualify.
|
||||
///
|
||||
/// Not here on purpose: `error.DestructiveImport`, which reports what an import
|
||||
/// would do to the database rather than the content of a file, and is the one
|
||||
/// config-shaped exit 2 `cli` decides for itself.
|
||||
const ConfigFault = validate.ValidateError || error{
|
||||
ParseZon,
|
||||
ConfigTooLarge,
|
||||
ManagedConfigUnreadable,
|
||||
NoUsableUpstreams,
|
||||
BadCertificate,
|
||||
};
|
||||
@@ -94,6 +103,14 @@ test "the faults raised outside the validator are configuration faults" {
|
||||
try testing.expect(isConfigFault(error.BadCertificate));
|
||||
}
|
||||
|
||||
test "a managed file the operator named and this process cannot open is exit 2" {
|
||||
// Ruling 2. The general rule below still holds — a bare `FileNotFound` is a
|
||||
// runtime failure — and this is the one converted form, produced only by
|
||||
// `config/loader.zig` for a path `--config` named.
|
||||
try testing.expect(isConfigFault(error.ManagedConfigUnreadable));
|
||||
try testing.expect(!isConfigFault(error.FileNotFound));
|
||||
}
|
||||
|
||||
test "the seed-file errors that used to exit 1 from run are configuration faults" {
|
||||
// D1 verbatim: these three reached `run` from a rejected seed file and were
|
||||
// classified as runtime failures.
|
||||
@@ -107,9 +124,9 @@ test "a runtime failure is not a configuration fault" {
|
||||
try testing.expect(!isConfigFault(error.AccessDenied));
|
||||
try testing.expect(!isConfigFault(error.FileNotFound));
|
||||
try testing.expect(!isConfigFault(error.AddressInUse));
|
||||
// A state conflict, not a bad file: `import` refuses to overwrite a
|
||||
// configured database and decides that exit code itself.
|
||||
try testing.expect(!isConfigFault(error.DatabaseNotEmpty));
|
||||
// A verdict on the diff, not on the file: `import` refuses a run that would
|
||||
// delete rows and decides that exit code itself.
|
||||
try testing.expect(!isConfigFault(error.DestructiveImport));
|
||||
// Only ever a warning, so it never reaches an exit code by this route.
|
||||
try testing.expect(!isConfigFault(error.SourceInNoGroup));
|
||||
}
|
||||
|
||||
+268
-675
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,323 @@
|
||||
//! The one way a configuration file becomes a `model.Config`.
|
||||
//!
|
||||
//! `nxdns run --config <file>` and `nxdns check --config <file>` must grade the
|
||||
//! same file the same way, so the read, the error classification and the parse
|
||||
//! live here rather than once per subcommand. `config/faults.zig` exists for the
|
||||
//! same reason one layer up: two copies of a rule are two rules.
|
||||
//!
|
||||
//! **The classification.** `faults.isConfigFault` deliberately excludes
|
||||
//! `FileNotFound` and `AccessDenied` in general — a missing file is usually a
|
||||
//! broken box, not a wrong configuration. The managed file is the one place
|
||||
//! where the opposite holds: the operator named that path, so a path that does
|
||||
//! not resolve is a configuration fault (exit 2, `nxdns check` is the next
|
||||
//! step). Only the path class converts:
|
||||
//!
|
||||
//! `FileNotFound`, `AccessDenied`, `PermissionDenied`, `NotDir`, `IsDir`,
|
||||
//! `SymLinkLoop`, `NameTooLong`, `BadPathName` → `ManagedConfigUnreadable`
|
||||
//!
|
||||
//! Everything else `readFileAllocOptions` can return — `SystemResources`, the
|
||||
//! two fd-quota errors, I/O failures, `OutOfMemory` — propagates unmapped and
|
||||
//! exits 1. Those are box faults a retry can clear, and the shipped unit carries
|
||||
//! `RestartPreventExitStatus=2 64`: mapping a transient failure to exit 2 would
|
||||
//! stop the service permanently on a fault that would have cleared itself.
|
||||
//!
|
||||
//! The mapping is a named error set switched exhaustively with
|
||||
//! `else => |other| return other`, so an error a Zig upgrade adds to
|
||||
//! `ReadFileAllocError` defaults to exit 1 rather than silently to exit 2.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const model = @import("model.zig");
|
||||
const validate = @import("validate.zig");
|
||||
|
||||
/// The ceiling on a configuration file. A file above it is a configuration
|
||||
/// fault, not a resource failure: nothing an operator writes by hand comes near
|
||||
/// 4 MiB, so this is a typo or a wrong path rather than a real config.
|
||||
pub const max_config_bytes = 4 * 1024 * 1024;
|
||||
|
||||
/// Every error `readFileAllocOptions` can hand back, plus the parse.
|
||||
pub const ReadError = std.Io.Dir.ReadFileAllocError;
|
||||
|
||||
/// The open failures that mean the operator's path is wrong rather than the box
|
||||
/// being broken. Spelled out as a set rather than as switch prongs so that the
|
||||
/// list is one thing a reader can find and a test can enumerate.
|
||||
pub const PathFault = error{
|
||||
FileNotFound,
|
||||
AccessDenied,
|
||||
PermissionDenied,
|
||||
NotDir,
|
||||
IsDir,
|
||||
SymLinkLoop,
|
||||
NameTooLong,
|
||||
BadPathName,
|
||||
};
|
||||
|
||||
pub const Error = ReadError || error{ ManagedConfigUnreadable, ConfigTooLarge, ParseZon };
|
||||
|
||||
/// The classification itself, pure and testable on its own: a path-class
|
||||
/// failure becomes `ManagedConfigUnreadable`, the size limit becomes
|
||||
/// `ConfigTooLarge`, and every other member travels unchanged.
|
||||
pub fn mapReadError(e: ReadError) Error {
|
||||
return switch (e) {
|
||||
error.StreamTooLong => error.ConfigTooLarge,
|
||||
error.FileNotFound,
|
||||
error.AccessDenied,
|
||||
error.PermissionDenied,
|
||||
error.NotDir,
|
||||
error.IsDir,
|
||||
error.SymLinkLoop,
|
||||
error.NameTooLong,
|
||||
error.BadPathName,
|
||||
=> error.ManagedConfigUnreadable,
|
||||
else => |other| other,
|
||||
};
|
||||
}
|
||||
|
||||
/// The file, NUL-terminated because `std.zon.parse` needs a sentinel and
|
||||
/// `readFileAlloc` cannot supply one. Errors travel exactly as the filesystem
|
||||
/// returned them: `nxdns import` reads an operator-supplied argument, not a
|
||||
/// managed file, and its exit codes are its own.
|
||||
pub fn readSource(
|
||||
io: std.Io,
|
||||
gpa: Allocator,
|
||||
dir: std.Io.Dir,
|
||||
path: []const u8,
|
||||
) ReadError![:0]u8 {
|
||||
return dir.readFileAllocOptions(io, path, gpa, .limited(max_config_bytes), .of(u8), 0);
|
||||
}
|
||||
|
||||
/// `readSource` under the managed-file classification, with the reason recorded
|
||||
/// as a diagnostic. A Zig error carries no text, so the path an operator has to
|
||||
/// go and fix reaches them through `Diagnostics` — the same channel every other
|
||||
/// configuration problem travels down, and the reason `check` and `run` print
|
||||
/// these in one shape.
|
||||
pub fn readManaged(
|
||||
io: std.Io,
|
||||
gpa: Allocator,
|
||||
dir: std.Io.Dir,
|
||||
path: []const u8,
|
||||
diags: *validate.Diagnostics,
|
||||
) Error![:0]u8 {
|
||||
return readSource(io, gpa, dir, path) catch |e| {
|
||||
switch (e) {
|
||||
error.StreamTooLong => try diags.add(
|
||||
error.ConfigTooLarge,
|
||||
"{s}",
|
||||
.{path},
|
||||
"larger than {d} bytes",
|
||||
.{max_config_bytes},
|
||||
),
|
||||
error.FileNotFound => try diags.add(
|
||||
error.ManagedConfigUnreadable,
|
||||
"{s}",
|
||||
.{path},
|
||||
"no such file",
|
||||
.{},
|
||||
),
|
||||
error.AccessDenied, error.PermissionDenied => try diags.add(
|
||||
error.ManagedConfigUnreadable,
|
||||
"{s}",
|
||||
.{path},
|
||||
"not readable",
|
||||
.{},
|
||||
),
|
||||
error.IsDir => try diags.add(
|
||||
error.ManagedConfigUnreadable,
|
||||
"{s}",
|
||||
.{path},
|
||||
"is a directory, not a configuration file",
|
||||
.{},
|
||||
),
|
||||
error.NotDir, error.SymLinkLoop, error.NameTooLong, error.BadPathName => try diags.add(
|
||||
error.ManagedConfigUnreadable,
|
||||
"{s}",
|
||||
.{path},
|
||||
"cannot be opened ({s})",
|
||||
.{@errorName(e)},
|
||||
),
|
||||
// A box fault. It exits 1 with its own name and records nothing: a
|
||||
// diagnostic would file it under "the configuration is wrong".
|
||||
else => {},
|
||||
}
|
||||
return mapReadError(e);
|
||||
};
|
||||
}
|
||||
|
||||
/// The line and column of a ZON syntax error are the only thing the operator can
|
||||
/// act on, so they travel the same channel as every other config problem: the
|
||||
/// caller's `Diagnostics`, which `run`, `check` and `import` all render. The
|
||||
/// global log is not that channel — an operator reading command output would see
|
||||
/// a bare `ParseZon` and nothing else.
|
||||
///
|
||||
/// `std.zon.parse.Diagnostics` renders one "line:column: error: text" line per
|
||||
/// problem, plus a "note:" line each, so each rendered line becomes one
|
||||
/// `Problem` and the list keeps the parser's order. Rendering them inline would
|
||||
/// put newlines inside a single `FAIL` record.
|
||||
pub fn reportParseFailure(
|
||||
diags: *validate.Diagnostics,
|
||||
zon_diag: *const std.zon.parse.Diagnostics,
|
||||
) error{OutOfMemory}!void {
|
||||
const rendered = try std.fmt.allocPrint(diags.gpa, "{f}", .{zon_diag});
|
||||
defer diags.gpa.free(rendered);
|
||||
|
||||
var lines = std.mem.splitScalar(u8, rendered, '\n');
|
||||
while (lines.next()) |line| {
|
||||
if (line.len == 0) continue;
|
||||
try diags.add(error.ParseZon, "config", .{}, "{s}", .{line});
|
||||
}
|
||||
}
|
||||
|
||||
/// The parse, with its failure rendered. The result is arena-owned and
|
||||
/// `std.zon.parse.free` is NEVER called on it: `Parser.parseStruct` fills an
|
||||
/// absent field by copying the struct's default straight through
|
||||
/// (parse.zig:874), so a defaulted `[]const u8` — and this model has many
|
||||
/// non-empty string defaults — points into the binary's read-only data.
|
||||
/// `parse.free` keeps no record of which fields were parsed and which were
|
||||
/// defaulted, so it would `@memset` and free rodata. Freeing the arena is the
|
||||
/// only correct release.
|
||||
pub fn parse(
|
||||
arena: Allocator,
|
||||
source: [:0]const u8,
|
||||
diags: *validate.Diagnostics,
|
||||
) error{ ParseZon, OutOfMemory }!model.Config {
|
||||
var zon_diag: std.zon.parse.Diagnostics = .{};
|
||||
return std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) {
|
||||
error.OutOfMemory => error.OutOfMemory,
|
||||
error.ParseZon => {
|
||||
try reportParseFailure(diags, &zon_diag);
|
||||
return error.ParseZon;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// Read and parse the managed file, everything a caller needs before
|
||||
/// `validate.validate`. Validation is deliberately left to the caller: `check`
|
||||
/// runs it beside its certificate and upstream probes, `run` runs it alone, and
|
||||
/// both call the same validator on the same `Config`, which is what makes the
|
||||
/// two agree.
|
||||
///
|
||||
/// `arena` owns both the source text and the returned configuration.
|
||||
pub fn load(
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
dir: std.Io.Dir,
|
||||
path: []const u8,
|
||||
diags: *validate.Diagnostics,
|
||||
) Error!model.Config {
|
||||
const source = try readManaged(io, arena, dir, path, diags);
|
||||
return parse(arena, source, diags);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "every path-class open failure is a managed-config fault" {
|
||||
inline for (@typeInfo(PathFault).error_set.?) |member| {
|
||||
const e = @field(ReadError, member.name);
|
||||
try testing.expectEqual(error.ManagedConfigUnreadable, mapReadError(e));
|
||||
}
|
||||
}
|
||||
|
||||
test "a box fault outside the path class propagates unmapped" {
|
||||
// Each of these exits 1: a retry can clear them, and the shipped unit's
|
||||
// `RestartPreventExitStatus=2 64` would make exit 2 permanent.
|
||||
try testing.expectEqual(error.SystemResources, mapReadError(error.SystemResources));
|
||||
try testing.expectEqual(error.ProcessFdQuotaExceeded, mapReadError(error.ProcessFdQuotaExceeded));
|
||||
try testing.expectEqual(error.SystemFdQuotaExceeded, mapReadError(error.SystemFdQuotaExceeded));
|
||||
try testing.expectEqual(error.OutOfMemory, mapReadError(error.OutOfMemory));
|
||||
try testing.expectEqual(error.InputOutput, mapReadError(error.InputOutput));
|
||||
}
|
||||
|
||||
test "the size limit is its own fault, not an unreadable path" {
|
||||
try testing.expectEqual(error.ConfigTooLarge, mapReadError(error.StreamTooLong));
|
||||
}
|
||||
|
||||
test "the path class is exactly the eight members the ruling names" {
|
||||
// A member added to `PathFault` without a decision recorded in the spec
|
||||
// fails here rather than quietly moving an exit code from 1 to 2.
|
||||
const expected = [_][]const u8{
|
||||
"FileNotFound", "AccessDenied", "PermissionDenied", "NotDir",
|
||||
"IsDir", "SymLinkLoop", "NameTooLong", "BadPathName",
|
||||
};
|
||||
const members = @typeInfo(PathFault).error_set.?;
|
||||
try testing.expectEqual(expected.len, members.len);
|
||||
inline for (members) |member| {
|
||||
var found = false;
|
||||
for (expected) |name| {
|
||||
if (std.mem.eql(u8, name, member.name)) found = true;
|
||||
}
|
||||
try testing.expect(found);
|
||||
}
|
||||
}
|
||||
|
||||
test "a missing managed file records the path and the reason" {
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
try testing.expectError(
|
||||
error.ManagedConfigUnreadable,
|
||||
load(testing.io, arena_state.allocator(), tmp.dir, "nope.zon", &diags),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 1), diags.failureCount());
|
||||
try testing.expectEqualStrings("nope.zon", diags.problems.items[0].path);
|
||||
try testing.expectEqualStrings("no such file", diags.problems.items[0].message);
|
||||
}
|
||||
|
||||
test "a directory named as the managed file is a configuration fault, not a crash" {
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.createDirPath(testing.io, "sub");
|
||||
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
try testing.expectError(
|
||||
error.ManagedConfigUnreadable,
|
||||
load(testing.io, arena_state.allocator(), tmp.dir, "sub", &diags),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 1), diags.failureCount());
|
||||
}
|
||||
|
||||
test "load parses a valid file and renders a syntax error line by line" {
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.writeFile(testing.io, .{ .sub_path = "good.zon", .data =
|
||||
\\.{
|
||||
\\ .groups = .{ .{ .name = "default" } },
|
||||
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
|
||||
\\}
|
||||
});
|
||||
try tmp.dir.writeFile(testing.io, .{ .sub_path = "bad.zon", .data = ".{ .groups = " });
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
var good_diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer good_diags.deinit();
|
||||
const cfg = try load(testing.io, arena, tmp.dir, "good.zon", &good_diags);
|
||||
try testing.expectEqual(@as(usize, 0), good_diags.problems.items.len);
|
||||
try testing.expectEqual(@as(usize, 1), cfg.upstreams.len);
|
||||
|
||||
var bad_diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer bad_diags.deinit();
|
||||
try testing.expectError(
|
||||
error.ParseZon,
|
||||
load(testing.io, arena, tmp.dir, "bad.zon", &bad_diags),
|
||||
);
|
||||
try testing.expect(bad_diags.failureCount() >= 1);
|
||||
try testing.expectEqualStrings("config", bad_diags.problems.items[0].path);
|
||||
}
|
||||
+76
-11
@@ -87,10 +87,16 @@ pub const Web = struct {
|
||||
enabled: bool = true,
|
||||
bind: []const u8 = "0.0.0.0",
|
||||
port: u16 = 8080,
|
||||
/// Operator input only. Never a settings row, always exported as "".
|
||||
password: []const u8 = "",
|
||||
/// argon2id PHC string; "" disables authentication.
|
||||
password_hash: []const u8 = "",
|
||||
/// Operator input only. Never a settings row, never exported.
|
||||
///
|
||||
/// Optional because absence and emptiness are different declarations: null
|
||||
/// means "the file says nothing about the password, keep the stored hash",
|
||||
/// while a present value is an instruction to set one.
|
||||
password: ?[]const u8 = null,
|
||||
/// argon2id PHC string. Null means "the file says nothing, keep what is
|
||||
/// stored"; an explicit `""` is the documented way to disable
|
||||
/// authentication.
|
||||
password_hash: ?[]const u8 = null,
|
||||
session_ttl_hours: u16 = 24,
|
||||
api_rate_limit_per_min: u32 = 300,
|
||||
/// Requests from the box itself skip the API rate limit. On by default: a
|
||||
@@ -387,9 +393,23 @@ fn isScalarSection(comptime T: type) bool {
|
||||
return @typeInfo(T) == .@"struct";
|
||||
}
|
||||
|
||||
/// `web.password` is operator input, never a settings row: it is hashed into
|
||||
/// `web.password_hash` at import time and discarded (S2.5).
|
||||
fn isSkipped(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
/// The skip policy splits by direction, because encode and decode need
|
||||
/// different sets.
|
||||
///
|
||||
/// `web.password` is operator input and is skipped both ways: it is hashed into
|
||||
/// `web.password_hash` and discarded (S2.5).
|
||||
///
|
||||
/// `web.password_hash` is skipped on **encode only**. The reconcile engine owns
|
||||
/// that settings row directly — ruling 4 of milestone 20 makes absence mean
|
||||
/// "keep the stored hash", which a general encode pass cannot express. Skipping
|
||||
/// it on decode as well would leave `cfg.web.password_hash` null on every read
|
||||
/// path, turn `auth.authEnabled` false, and silently open the admin UI.
|
||||
fn isEncodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
if (!std.mem.eql(u8, section, "web")) return false;
|
||||
return std.mem.eql(u8, field, "password") or std.mem.eql(u8, field, "password_hash");
|
||||
}
|
||||
|
||||
fn isDecodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
|
||||
}
|
||||
|
||||
@@ -416,6 +436,10 @@ fn decodeValue(comptime T: type, text: []const u8) error{BadSettingValue}!T {
|
||||
.int => std.fmt.parseInt(T, text, 10) catch error.BadSettingValue,
|
||||
.@"enum" => T.fromDb(text) orelse error.BadSettingValue,
|
||||
.pointer => text,
|
||||
// A stored key is a present value, so an optional field decodes to a
|
||||
// non-null one; the null stays reserved for the absent key, which never
|
||||
// reaches this function at all.
|
||||
.optional => |info| try decodeValue(info.child, text),
|
||||
else => @compileError("unsupported setting field type " ++ @typeName(T)),
|
||||
};
|
||||
}
|
||||
@@ -441,7 +465,7 @@ pub fn toSettings(cfg: Config, gpa: Allocator, out: *std.ArrayList(SettingPair))
|
||||
if (comptime isScalarSection(section_field.type)) {
|
||||
const section = @field(cfg, section_field.name);
|
||||
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
if (comptime !isSkipped(section_field.name, field.name)) {
|
||||
if (comptime !isEncodeSkipped(section_field.name, field.name)) {
|
||||
const value = try encodeValue(field.type, @field(section, field.name), gpa);
|
||||
errdefer gpa.free(value);
|
||||
try out.append(gpa, .{ .key = section_field.name ++ "." ++ field.name, .value = value });
|
||||
@@ -465,7 +489,7 @@ pub fn fromSettings(pairs: []const SettingPair, cfg: *Config, unknown_keys: *usi
|
||||
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
|
||||
if (comptime isScalarSection(section_field.type)) {
|
||||
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
if (comptime !isSkipped(section_field.name, field.name)) {
|
||||
if (comptime !isDecodeSkipped(section_field.name, field.name)) {
|
||||
if (std.mem.eql(u8, pair.key, section_field.name ++ "." ++ field.name)) {
|
||||
@field(@field(cfg, section_field.name), field.name) =
|
||||
try decodeValue(field.type, pair.value);
|
||||
@@ -531,7 +555,6 @@ const expected_keys = [_][]const u8{
|
||||
"web.api_rate_limit_per_min",
|
||||
"web.bind",
|
||||
"web.enabled",
|
||||
"web.password_hash",
|
||||
"web.port",
|
||||
"web.session_ttl_hours",
|
||||
"web.sse_max_connections_per_ip",
|
||||
@@ -642,7 +665,7 @@ test "toSettings and fromSettings round-trip a non-default config" {
|
||||
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
|
||||
if (comptime isScalarSection(section_field.type)) {
|
||||
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
if (comptime !isSkipped(section_field.name, field.name)) {
|
||||
if (comptime !isEncodeSkipped(section_field.name, field.name)) {
|
||||
const a = @field(@field(original, section_field.name), field.name);
|
||||
const b = @field(@field(restored, section_field.name), field.name);
|
||||
if (comptime @typeInfo(field.type) == .pointer) {
|
||||
@@ -671,6 +694,48 @@ test "an unknown settings key is counted and not an error" {
|
||||
try testing.expectEqual(@as(usize, 2), unknown);
|
||||
}
|
||||
|
||||
test "web.password_hash decodes from the settings table but is never encoded" {
|
||||
const gpa = testing.allocator;
|
||||
var pairs: std.ArrayList(SettingPair) = .empty;
|
||||
defer {
|
||||
freeSettings(gpa, pairs.items);
|
||||
pairs.deinit(gpa);
|
||||
}
|
||||
|
||||
// Encode: the reconciler owns that row, so no pass over the model emits it.
|
||||
const hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def";
|
||||
try toSettings(.{ .web = .{ .password_hash = hash } }, gpa, &pairs);
|
||||
for (pairs.items) |pair| {
|
||||
try testing.expect(!std.mem.eql(u8, pair.key, "web.password_hash"));
|
||||
try testing.expect(!std.mem.eql(u8, pair.key, "web.password"));
|
||||
}
|
||||
|
||||
// Decode: every read path still sees the stored hash, or `authEnabled`
|
||||
// would read false on a box that has a password set.
|
||||
var cfg: Config = .{};
|
||||
var unknown: usize = 0;
|
||||
const stored = [_]SettingPair{.{ .key = "web.password_hash", .value = hash }};
|
||||
try fromSettings(&stored, &cfg, &unknown);
|
||||
try testing.expectEqual(@as(usize, 0), unknown);
|
||||
try testing.expectEqualStrings(hash, cfg.web.password_hash.?);
|
||||
}
|
||||
|
||||
test "an optional settings field is null when absent and non-null when present" {
|
||||
var absent: Config = .{};
|
||||
var unknown: usize = 0;
|
||||
const other = [_]SettingPair{.{ .key = "dns.port", .value = "5300" }};
|
||||
try fromSettings(&other, &absent, &unknown);
|
||||
try testing.expectEqual(@as(?[]const u8, null), absent.web.password_hash);
|
||||
|
||||
// An explicit empty string is a present value, not an absent key: it is how
|
||||
// a config file disables authentication.
|
||||
var empty: Config = .{};
|
||||
const disabled = [_]SettingPair{.{ .key = "web.password_hash", .value = "" }};
|
||||
try fromSettings(&disabled, &empty, &unknown);
|
||||
try testing.expect(empty.web.password_hash != null);
|
||||
try testing.expectEqualStrings("", empty.web.password_hash.?);
|
||||
}
|
||||
|
||||
test "a malformed settings value is BadSettingValue" {
|
||||
var cfg: Config = .{};
|
||||
var unknown: usize = 0;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+56
-2
@@ -101,6 +101,7 @@ pub const ValidateError = error{
|
||||
MissingKeyPath,
|
||||
MissingLogPath,
|
||||
PasswordAndHashBothSet,
|
||||
EmptyWebPassword,
|
||||
};
|
||||
|
||||
/// What `validate` returns: a verdict on the configuration, or the allocation
|
||||
@@ -114,7 +115,19 @@ pub const Error = ValidateError || Allocator.Error;
|
||||
/// same channel so a syntax error's line/column reaches the operator's output,
|
||||
/// plus the warnings — which are never returned by `validate` and so are not
|
||||
/// `ValidateError` members.
|
||||
pub const ProblemError = ValidateError || error{ ParseZon, SourceInNoGroup };
|
||||
/// Wider than `ValidateError`: the diagnostic channel also carries the problems
|
||||
/// found before the validator ever sees a `Config` — the ZON parse, the managed
|
||||
/// file that would not open (`config/loader.zig`), the file above the size limit
|
||||
/// — and the one found after it, an import whose diff would delete rows. The
|
||||
/// validator itself records only `ValidateError` members, which is what makes
|
||||
/// `validate`'s `@errorCast` of its own findings checked-safe.
|
||||
pub const ProblemError = ValidateError || error{
|
||||
ParseZon,
|
||||
SourceInNoGroup,
|
||||
ManagedConfigUnreadable,
|
||||
ConfigTooLarge,
|
||||
DestructiveImport,
|
||||
};
|
||||
|
||||
/// `.fail` rejects the configuration and is what an exit code is computed from.
|
||||
/// `.warn` reports something legal that is almost certainly not what the
|
||||
@@ -392,7 +405,10 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
|
||||
|
||||
try checkBind(diags, cfg.web.bind, "web.bind", .any);
|
||||
try checkPort(diags, cfg.web.port, "web.port");
|
||||
if (cfg.web.password.len != 0 and cfg.web.password_hash.len != 0) {
|
||||
// Both fields are optional, and absence is the third state: a file that
|
||||
// states neither keeps the stored hash. So the test is on presence, not on
|
||||
// length.
|
||||
if (cfg.web.password != null and cfg.web.password_hash != null) {
|
||||
try diags.add(
|
||||
error.PasswordAndHashBothSet,
|
||||
"web.password",
|
||||
@@ -401,6 +417,22 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
|
||||
.{},
|
||||
);
|
||||
}
|
||||
// A present-but-empty password would hash the empty string into a non-empty
|
||||
// PHC — authentication on — while every login with an empty password is
|
||||
// refused: authentication on and unreachable. The remedy is named, because
|
||||
// the operator who wrote this meant one of two other things.
|
||||
if (cfg.web.password) |password| {
|
||||
if (password.len == 0) {
|
||||
try diags.add(
|
||||
error.EmptyWebPassword,
|
||||
"web.password",
|
||||
.{},
|
||||
"password is set to the empty string; omit the field to keep the stored password, " ++
|
||||
"or set password_hash = \"\" to disable authentication",
|
||||
.{},
|
||||
);
|
||||
}
|
||||
}
|
||||
// A session TTL is a TTL; `BadTtl` is its bucket.
|
||||
if (cfg.web.session_ttl_hours < 1) {
|
||||
try diags.add(error.BadTtl, "web.session_ttl_hours", .{}, "must be at least 1", .{});
|
||||
@@ -1968,6 +2000,28 @@ test "error.PasswordAndHashBothSet" {
|
||||
try expectProblem(cfg, error.PasswordAndHashBothSet, "web.password");
|
||||
}
|
||||
|
||||
test "error.EmptyWebPassword names password_hash as the way to disable auth" {
|
||||
var cfg = baseConfig();
|
||||
cfg.web.password = "";
|
||||
try expectProblem(cfg, error.EmptyWebPassword, "web.password");
|
||||
|
||||
// The remedy has to be in the text: the operator who wrote `password = ""`
|
||||
// meant either "keep the current one" or "turn authentication off", and the
|
||||
// diagnostic is the only place that distinction is spelled out.
|
||||
var diags: Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
try testing.expectError(error.EmptyWebPassword, validate(cfg, &diags));
|
||||
try testing.expect(std.mem.indexOf(u8, diags.problems.items[0].message, "password_hash = \"\"") != null);
|
||||
|
||||
// Absence is not emptiness: a file that states no password is legal and
|
||||
// means "keep the stored hash".
|
||||
var absent = baseConfig();
|
||||
absent.web.password = null;
|
||||
var quiet: Diagnostics = .init(testing.allocator);
|
||||
defer quiet.deinit();
|
||||
try validate(absent, &quiet);
|
||||
}
|
||||
|
||||
test "a config with five distinct problems yields five diagnostics and the first error" {
|
||||
var cfg = baseConfig();
|
||||
cfg.dns.port = 0; // BadPort, first in check order
|
||||
|
||||
@@ -22,6 +22,7 @@ const build_options = @import("build_options");
|
||||
const net = std.Io.net;
|
||||
|
||||
const model = @import("../config/model.zig");
|
||||
const reconcile = @import("../config/reconcile.zig");
|
||||
const db = @import("../storage/db.zig");
|
||||
const migrations = @import("../storage/migrations.zig");
|
||||
const context = @import("../storage/repositories/context.zig");
|
||||
@@ -400,6 +401,10 @@ const HttpFixture = struct {
|
||||
server: net.Server,
|
||||
body: []const u8,
|
||||
route: std.atomic.Value(u8),
|
||||
/// Connections accepted, whatever came over them. A test that claims a pass
|
||||
/// downloaded nothing reads this rather than the route counters: a refetch
|
||||
/// that failed on the wire is still a refetch, and this counts it.
|
||||
accepted: std.atomic.Value(u32),
|
||||
/// How many parts the `chunked` route has flushed. The test reads it to
|
||||
/// prove the reply really left this server in pieces, because a `Writer`
|
||||
/// reports a buffered part as written and would otherwise hide a fixture
|
||||
@@ -422,6 +427,7 @@ const HttpFixture = struct {
|
||||
.server = try local.listen(io, .{ .reuse_address = true }),
|
||||
.body = body,
|
||||
.route = .init(@intFromEnum(Route.body)),
|
||||
.accepted = .init(0),
|
||||
.flushed_parts = .init(0),
|
||||
.stall_reached = .unset,
|
||||
.stall_release = .unset,
|
||||
@@ -449,6 +455,7 @@ const HttpFixture = struct {
|
||||
while (true) {
|
||||
var stream = self.server.accept(io) catch return;
|
||||
defer stream.close(io);
|
||||
_ = self.accepted.fetchAdd(1, .monotonic);
|
||||
|
||||
var read_buf: [8192]u8 = undefined;
|
||||
var write_buf: [8192]u8 = undefined;
|
||||
@@ -1372,6 +1379,112 @@ test "10d: a source deleted mid-refresh does not take the refresh's temporary fi
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10e: the restart invariant, across the config engine and the filter layer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "10e: a reconcile then a restart reuses the compiled files and downloads nothing" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
const env = try Env.create(gpa);
|
||||
defer env.destroy();
|
||||
const io = env.io();
|
||||
|
||||
var fixture = try HttpFixture.init(io, http_body);
|
||||
defer fixture.deinit(io);
|
||||
var group: std.Io.Group = .init;
|
||||
defer group.cancel(io);
|
||||
try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });
|
||||
|
||||
var url_buf: [64]u8 = undefined;
|
||||
const url = try fixture.url(&url_buf);
|
||||
const id = try seedSource(&env.database, url);
|
||||
|
||||
// One real download, so the compiled artifacts exist and are named after
|
||||
// the row id the rest of this test is about.
|
||||
try testing.expect(try refreshOnce(env, url));
|
||||
try testing.expectEqual(@as(u32, 1), fixture.accepted.load(.monotonic));
|
||||
|
||||
var dir = try env.blocklistDir();
|
||||
defer dir.close(io);
|
||||
var list_buf: [64]u8 = undefined;
|
||||
var wild_buf: [64]u8 = undefined;
|
||||
const list_name = try std.fmt.bufPrint(&list_buf, "{d}.list", .{id});
|
||||
const wild_name = try std.fmt.bufPrint(&wild_buf, "{d}.wild", .{id});
|
||||
const list_before = try dir.statFile(io, list_name, .{});
|
||||
const wild_before = try dir.statFile(io, wild_name, .{});
|
||||
|
||||
// File mode, declaring exactly what the database already holds. The engine
|
||||
// has to recognise the source by its url and leave the row where it is:
|
||||
// the compiled files are named after that id, and the manager looks for
|
||||
// them under the same number.
|
||||
const cfg: model.Config = .{
|
||||
.groups = &.{.{ .name = "default" }},
|
||||
.blocklist_sources = &.{.{ .url = url, .name = source_name }},
|
||||
.group_sources = &.{.{ .group = "default", .source_url = url }},
|
||||
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
|
||||
};
|
||||
var pass = try reconcile.begin(
|
||||
io,
|
||||
gpa,
|
||||
&env.database,
|
||||
cfg,
|
||||
std.Io.Clock.real.now(io).toSeconds(),
|
||||
.{},
|
||||
);
|
||||
errdefer pass.rollback();
|
||||
// Committed before the manager comes up, which is the ordering the invariant
|
||||
// rests on: a manager that read the table mid-transaction could see either
|
||||
// half of a source it is about to look for on disk.
|
||||
try pass.commit();
|
||||
|
||||
// The restart. The old manager is gone and a new one comes up over the same
|
||||
// directory and the same database with nothing carried across in memory.
|
||||
// `runScheduler` is the boot sequence the server runs — the orphan sweep,
|
||||
// then the startup pass — and a disabled update makes it return rather than
|
||||
// wait out an interval.
|
||||
env.mgr.deinit(io);
|
||||
env.mgr = try manager.Manager.init(
|
||||
gpa,
|
||||
&env.database,
|
||||
.{ .dir = env.tmp.dir },
|
||||
&env.f,
|
||||
.{ .enabled = false },
|
||||
budget,
|
||||
);
|
||||
try env.mgr.runScheduler(io);
|
||||
|
||||
// Nothing was downloaded. The server is still listening, so this is a
|
||||
// decision the pass made rather than a connection it could not have opened.
|
||||
try testing.expectEqual(@as(u32, 1), fixture.accepted.load(.monotonic));
|
||||
|
||||
// The same two files: not recompiled, and not swept as orphans and written
|
||||
// back.
|
||||
const list_after = try dir.statFile(io, list_name, .{});
|
||||
const wild_after = try dir.statFile(io, wild_name, .{});
|
||||
try testing.expectEqual(list_before.inode, list_after.inode);
|
||||
try testing.expectEqual(list_before.mtime, list_after.mtime);
|
||||
try testing.expectEqual(wild_before.inode, wild_after.inode);
|
||||
try testing.expectEqual(wild_before.mtime, wild_after.mtime);
|
||||
|
||||
// The row kept the id those files are named after, and the snapshot the
|
||||
// restart published is the one compiled from them.
|
||||
var rows = try listRows(&env.database);
|
||||
defer rows.deinit();
|
||||
try testing.expectEqual(id, (try rows.byUrl(url)).id);
|
||||
try testing.expectEqual(manager.State.ok, (try env.status(id)).state);
|
||||
|
||||
// Asserted last, after the behaviour it explains: the engine wrote no row
|
||||
// at all, which is why the id above survived and why the restart above had
|
||||
// files to find.
|
||||
try testing.expectEqual(@as(u32, 0), pass.summary.sources.total());
|
||||
|
||||
const decision, _ = try env.evaluate("ads.example.com");
|
||||
try testing.expect(decision.blocked);
|
||||
try testing.expectEqual(matcher.Reason.blocklist_domain, decision.reason);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 11–12: local records, from the database to the wire
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1199,6 +1199,14 @@ pub const Manager = struct {
|
||||
|
||||
if (state != .ok) return true;
|
||||
const last = row.last_updated orelse return true;
|
||||
// The Pi has no RTC, so a fetch stamped while the clock ran ahead of
|
||||
// real time (a pre-NTP boot, a restored image) leaves a `last_updated`
|
||||
// in the future. Plain interval arithmetic would then suspend every
|
||||
// refresh until real time caught up with the poison stamp, and the
|
||||
// reconcile engine preserves runtime columns faithfully, so nothing
|
||||
// else would ever clear it. A stamp from the future is not evidence of
|
||||
// a recent fetch.
|
||||
if (last > now) return true;
|
||||
return now - last >= model.updateIntervalSeconds(self.update);
|
||||
}
|
||||
|
||||
@@ -1674,6 +1682,40 @@ test "acquire before any reload returns null and holds no lock" {
|
||||
manager.lock.unlock(io);
|
||||
}
|
||||
|
||||
test "needsRefresh treats a last_updated in the future as due" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var f: fetcher.Fetcher = undefined;
|
||||
var manager = try testManager(&database, &f);
|
||||
defer manager.deinit(io);
|
||||
|
||||
// `.ok` is the only state that consults the clock at all; every other one
|
||||
// is already due, so the arithmetic below would be unreachable without it.
|
||||
var statuses = [_]SourceStatus{.{ .id = 1, .state = .ok }};
|
||||
manager.statuses = &statuses;
|
||||
defer manager.statuses = &.{};
|
||||
|
||||
const row = testRow(1, true);
|
||||
const stamp = row.last_updated.?;
|
||||
const interval = model.updateIntervalSeconds(manager.update);
|
||||
|
||||
// The ordinary cases still hold: fresh is not due, stale is.
|
||||
try testing.expect(!manager.needsRefresh(io, row, stamp + 1));
|
||||
try testing.expect(manager.needsRefresh(io, row, stamp + interval));
|
||||
|
||||
// The Pi has no RTC. A fetch stamped while the clock ran ahead of real
|
||||
// time leaves `now - last` negative, which reads as "fetched moments ago"
|
||||
// and suspends every refresh until real time catches the poison stamp —
|
||||
// for a whole day here, and for as long as the clock was wrong in general.
|
||||
try testing.expect(manager.needsRefresh(io, row, stamp - 1));
|
||||
try testing.expect(manager.needsRefresh(io, row, stamp - 86_400));
|
||||
}
|
||||
|
||||
test "the disk gate skips a scheduled refresh only while writes are critical" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
@@ -965,10 +965,12 @@ test "S7 case 11: the app boots, serves a query and exits zero on shutdown" {
|
||||
shutdown.reset();
|
||||
defer shutdown.reset();
|
||||
|
||||
var future = try test_io.concurrent(app.run, .{ runner, cli.RunArgs{ .paths = .{
|
||||
.data_dir = root,
|
||||
// `--config` present: the file is authority and the database is converged
|
||||
// onto it before the listeners bind (milestone-20 ruling 1).
|
||||
var future = try test_io.concurrent(app.run, .{ runner, cli.RunArgs{
|
||||
.paths = .{ .data_dir = root },
|
||||
.config = config_path,
|
||||
} } });
|
||||
} });
|
||||
|
||||
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
const client = try client_address.bind(test_io, .{ .mode = .dgram });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The `config.db` schema, verbatim from PLAN §11.2, plus the two table orders
|
||||
//! every other storage session needs.
|
||||
//! The `config.db` schema, verbatim from PLAN §11.2, plus the table lists every
|
||||
//! other storage session needs.
|
||||
//!
|
||||
//! The DDL text is data, not code: `migrations.zig` carries it as step 1 and
|
||||
//! never edits it in place. A schema change is a *new* step with new DDL, so
|
||||
@@ -89,8 +89,10 @@ pub const ddl_v1: [:0]const u8 =
|
||||
\\CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
;
|
||||
|
||||
/// Child-before-parent. Used by import's wipe step; correct under
|
||||
/// `foreign_keys = ON`.
|
||||
/// Child-before-parent, and correct under `foreign_keys = ON`. The reconcile
|
||||
/// engine deletes in this order so that every declarative child of a dying
|
||||
/// parent is removed — and counted — before the parent goes, which keeps the FK
|
||||
/// cascades a safety net rather than the accountant.
|
||||
///
|
||||
/// `upstreams`, `local_records`, `forward_zones` and `settings` have no foreign
|
||||
/// keys, so their position is free; `groups` and `blocklist_sources` must come
|
||||
@@ -102,18 +104,28 @@ pub const delete_order = [_][]const u8{
|
||||
"blocklist_sources", "groups",
|
||||
};
|
||||
|
||||
/// Every table whose emptiness defines "the database has never been configured"
|
||||
/// (S5.2). `groups` is absent because migration step 1 seeds `(1, 'default')`,
|
||||
/// so an empty database still holds one group row; `schema_version` is absent
|
||||
/// for the same reason.
|
||||
pub const content_tables = [_][]const u8{
|
||||
"clients", "client_prefixes", "upstreams", "blocklist_sources",
|
||||
"group_sources", "rules", "local_records", "forward_zones",
|
||||
"settings",
|
||||
/// Every table that holds configuration, in a fixed order. It includes
|
||||
/// `groups`, because a byte-stability dump has to be able to see a reconcile
|
||||
/// that renumbered a group.
|
||||
///
|
||||
/// `schema_version` is absent: it is the migration's, not the operator's.
|
||||
pub const table_names = [_][]const u8{
|
||||
"groups", "clients", "client_prefixes", "upstreams",
|
||||
"blocklist_sources", "group_sources", "rules", "local_records",
|
||||
"forward_zones", "settings",
|
||||
};
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "table_names names exactly the tables delete_order does" {
|
||||
try testing.expectEqual(delete_order.len, table_names.len);
|
||||
for (delete_order) |name| {
|
||||
try testing.expect(indexOf(&table_names, name) != null);
|
||||
}
|
||||
try testing.expect(indexOf(&table_names, "groups") != null);
|
||||
try testing.expect(indexOf(&table_names, "schema_version") == null);
|
||||
}
|
||||
|
||||
test "delete_order lists every referrer before the table it references" {
|
||||
// The two parents in the schema. Every child that references them must be
|
||||
// deleted first, or `foreign_keys = ON` turns import's wipe into a
|
||||
@@ -130,15 +142,6 @@ test "delete_order lists every referrer before the table it references" {
|
||||
}
|
||||
}
|
||||
|
||||
test "content_tables is delete_order without groups" {
|
||||
try testing.expectEqual(delete_order.len - 1, content_tables.len);
|
||||
for (content_tables) |name| {
|
||||
try testing.expect(indexOf(&delete_order, name) != null);
|
||||
}
|
||||
try testing.expect(indexOf(&content_tables, "groups") == null);
|
||||
try testing.expect(indexOf(&content_tables, "schema_version") == null);
|
||||
}
|
||||
|
||||
fn indexOf(haystack: []const []const u8, needle: []const u8) ?usize {
|
||||
for (haystack, 0..) |item, i| {
|
||||
if (std.mem.eql(u8, item, needle)) return i;
|
||||
|
||||
@@ -57,6 +57,7 @@ pub const c = struct {
|
||||
pub extern fn sqlite3_column_bytes(stmt: *c.Stmt, col: c_int) c_int;
|
||||
pub extern fn sqlite3_last_insert_rowid(db: *Sqlite3) i64;
|
||||
pub extern fn sqlite3_changes(db: *Sqlite3) c_int;
|
||||
pub extern fn sqlite3_total_changes(db: *Sqlite3) c_int;
|
||||
};
|
||||
|
||||
/// Result codes, from the vendored `sqlite3.h` (3.53.4).
|
||||
@@ -429,6 +430,15 @@ pub const Db = struct {
|
||||
pub fn changes(self: *Db) i64 {
|
||||
return c.sqlite3_changes(self.handle);
|
||||
}
|
||||
|
||||
/// Every row this connection has inserted, updated or deleted since it was
|
||||
/// opened. Monotonic, so a caller proves "this call wrote nothing" by
|
||||
/// reading it either side and comparing — which is stronger than comparing
|
||||
/// content, because an UPDATE that rewrites identical values still moves
|
||||
/// this counter.
|
||||
pub fn totalChanges(self: *Db) i64 {
|
||||
return c.sqlite3_total_changes(self.handle);
|
||||
}
|
||||
};
|
||||
|
||||
fn openHandle(filename: [:0]const u8, flags: c_int) Error!*c.Sqlite3 {
|
||||
|
||||
@@ -353,7 +353,7 @@ test "readVersion reads a file database through an immutable open, writing nothi
|
||||
try testing.expectEqual(@as(u32, 1), try readVersion(&database));
|
||||
}
|
||||
|
||||
test "delete_order and content_tables name exactly the tables the schema creates" {
|
||||
test "delete_order and table_names name exactly the tables the schema creates" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
_ = try migrate(&database);
|
||||
@@ -361,7 +361,7 @@ test "delete_order and content_tables name exactly the tables the schema creates
|
||||
for (config_schema.delete_order) |name| {
|
||||
try testing.expect(try tableExists(&database, name));
|
||||
}
|
||||
for (config_schema.content_tables) |name| {
|
||||
for (config_schema.table_names) |name| {
|
||||
try testing.expect(try tableExists(&database, name));
|
||||
}
|
||||
// delete_order covers every table except `schema_version`.
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
//! `listClients` returns only `hand_edited = 1` rows. A client the server
|
||||
//! materialised from live traffic is runtime state, not configuration, and must
|
||||
//! not appear in an export. `hand_edited` is the only marker of operator intent
|
||||
//! in this table, so it also decides what `import.isEmpty` counts: a database
|
||||
//! carrying nothing but materialised rows has never been configured, and a seed
|
||||
//! file must still be able to fill it. `countClients` counts **all** rows and is
|
||||
//! a test helper — it deliberately does not answer that question.
|
||||
//! in this table, so it also decides what the reconcile engine may delete: a
|
||||
//! declared row the file drops is removed, an observed row is kept whatever the
|
||||
//! file says, and declaring an observed address promotes that row in place.
|
||||
//! `countClients` counts **all** rows and is a test helper.
|
||||
//!
|
||||
//! The import path is list / insert / deleteAll / count, plus the two runtime
|
||||
//! calls `upsertSeen` and `pruneStale` that `server/clients.zig`'s tracker owns.
|
||||
//! The configuration path is list / insert / update / delete / count, plus the
|
||||
//! two runtime calls `upsertSeen` and `pruneStale` that `server/clients.zig`'s
|
||||
//! tracker owns.
|
||||
//! The REST surface is the third section: it speaks row ids and shows
|
||||
//! every client, materialised ones included.
|
||||
|
||||
@@ -126,7 +127,7 @@ pub fn deleteAllClients(database: *db.Db) db.Error!void {
|
||||
}
|
||||
|
||||
/// Counts every row, including the materialised ones `listClients` filters out.
|
||||
/// Used by tests; `import.isEmpty` counts operator intent instead.
|
||||
/// Used by tests.
|
||||
pub fn countClients(database: *db.Db) db.Error!i64 {
|
||||
return database.queryInt("SELECT count(*) FROM clients");
|
||||
}
|
||||
@@ -407,6 +408,67 @@ pub fn replaceClientPrefixes(database: *db.Db, items: []const ClientPrefixInput)
|
||||
try tx.commit();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reconcile surface (milestone 20)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// `replaceClientPrefixes` above is the REST list resource: one atomic swap of
|
||||
// the whole table, in a transaction of its own. The reconcile engine cannot use
|
||||
// it — it runs inside a transaction already, and rewriting every row would
|
||||
// forfeit the row ids and the zero-writes property the engine exists for — so
|
||||
// it edits and removes prefixes one at a time instead.
|
||||
|
||||
/// Writes the two columns a prefix row carries besides its identity.
|
||||
///
|
||||
/// `error.NotFound`: no prefix holds `id`. `error.Constraint`:
|
||||
/// `client_prefixes.prefix` is UNIQUE, or `group_id` names no group.
|
||||
pub fn updateClientPrefix(database: *db.Db, id: i64, item: ClientPrefixInput) db.Error!void {
|
||||
var stmt = try database.prepare(
|
||||
"UPDATE client_prefixes SET prefix = ?2, group_id = ?3, priority = ?4 WHERE id = ?1",
|
||||
);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
try stmt.bindText(2, item.prefix);
|
||||
try stmt.bindInt(3, item.group_id);
|
||||
try stmt.bindInt(4, item.priority);
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
/// `error.NotFound`: no prefix holds `id`. Nothing references
|
||||
/// `client_prefixes`, so a delete cannot violate a constraint.
|
||||
pub fn deleteClientPrefix(database: *db.Db, id: i64) db.Error!void {
|
||||
var stmt = try database.prepare("DELETE FROM client_prefixes WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
/// Moves the observed clients of one group to another, and reports how many
|
||||
/// rows moved.
|
||||
///
|
||||
/// `clients.group_id` references `groups(id)` with no `ON DELETE` action
|
||||
/// (config_schema.zig:26), so a group that any client still sits in cannot be
|
||||
/// deleted. When a configuration stops declaring a group, its *declared*
|
||||
/// clients go with it, but the devices the DNS path materialised into it did
|
||||
/// not come from the configuration and must not be deleted for a decision that
|
||||
/// was never about them. They move to the default group, which is also the
|
||||
/// semantics the operator asked for: they un-declared the group, not the
|
||||
/// devices.
|
||||
///
|
||||
/// `hand_edited = 1` rows are untouched — those are configuration, and the
|
||||
/// reconcile engine has already accounted for them.
|
||||
pub fn reassignObservedClients(database: *db.Db, from_group_id: i64, to_group_id: i64) db.Error!u32 {
|
||||
var stmt = try database.prepare(
|
||||
"UPDATE clients SET group_id = ?2 WHERE hand_edited = 0 AND group_id = ?1",
|
||||
);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, from_group_id);
|
||||
try stmt.bindInt(2, to_group_id);
|
||||
try stmt.exec();
|
||||
const moved = database.changes();
|
||||
return @intCast(@min(moved, std.math.maxInt(u32)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -254,6 +254,41 @@ pub fn setGroupSources(database: *db.Db, group_id: i64, source_ids: []const i64)
|
||||
try tx.commit();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reconcile surface (milestone 20)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// `group_sources` has no row id — the pair *is* the identity — so the reconcile
|
||||
// engine matches on the pair and needs the ids the name-keyed list above
|
||||
// resolves away.
|
||||
|
||||
pub const GroupSourcePair = struct { group_id: i64, source_id: i64 };
|
||||
|
||||
/// Every assignment as the pair of ids it is. Nothing to free.
|
||||
pub fn listGroupSourcePairs(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(GroupSourcePair) {
|
||||
return crud.listRows(
|
||||
GroupSourcePair,
|
||||
database,
|
||||
gpa,
|
||||
"SELECT group_id, source_id FROM group_sources ORDER BY group_id, source_id",
|
||||
readGroupSourcePair,
|
||||
);
|
||||
}
|
||||
|
||||
fn readGroupSourcePair(stmt: *db.Stmt, gpa: Allocator) db.Error!GroupSourcePair {
|
||||
_ = gpa;
|
||||
return .{ .group_id = stmt.columnInt(0), .source_id = stmt.columnInt(1) };
|
||||
}
|
||||
|
||||
/// Removes one assignment. `error.NotFound`: no row holds the pair.
|
||||
pub fn deleteGroupSourcePair(database: *db.Db, pair: GroupSourcePair) db.Error!void {
|
||||
var stmt = try database.prepare("DELETE FROM group_sources WHERE group_id = ?1 AND source_id = ?2");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, pair.group_id);
|
||||
try stmt.bindInt(2, pair.source_id);
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
fn groupExists(database: *db.Db, id: i64) db.Error!bool {
|
||||
var stmt = try database.prepare("SELECT 1 FROM groups WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
|
||||
@@ -88,6 +88,18 @@ pub fn putSetting(database: *db.Db, key: []const u8, value: []const u8) db.Error
|
||||
try stmt.exec();
|
||||
}
|
||||
|
||||
/// Removes one key. Silent about a key that is not stored: the reconcile
|
||||
/// engine's sweep computes the set of keys to drop from a list it has already
|
||||
/// read, so "no such row" is not a caller error the way it is for a by-id
|
||||
/// mutation, and `execStrict` would turn a harmless race into a failed
|
||||
/// transaction.
|
||||
pub fn deleteSetting(database: *db.Db, key: []const u8) db.Error!void {
|
||||
var stmt = try database.prepare("DELETE FROM settings WHERE key = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindText(1, key);
|
||||
return stmt.exec();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -22,7 +22,6 @@ const build_options = @import("build_options");
|
||||
const Writer = std.Io.Writer;
|
||||
|
||||
const cli = @import("../cli.zig");
|
||||
const bootstrap = @import("../config/bootstrap.zig");
|
||||
const config_export = @import("../config/export.zig");
|
||||
const import = @import("../config/import.zig");
|
||||
const model = @import("../config/model.zig");
|
||||
@@ -127,7 +126,7 @@ fn openMigrated(f: *const Fixture, name: []const u8) !Data {
|
||||
return .{ .dir = dir, .database = database };
|
||||
}
|
||||
|
||||
fn importInto(f: *const Fixture, data: *Data, file: []const u8, force: bool) !void {
|
||||
fn importInto(f: *const Fixture, data: *Data, file: []const u8, allow_delete: bool) !void {
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
return import.importFile(
|
||||
@@ -136,7 +135,7 @@ fn importInto(f: *const Fixture, data: *Data, file: []const u8, force: bool) !vo
|
||||
&data.database,
|
||||
f.tmp.dir,
|
||||
file,
|
||||
.{ .force = force },
|
||||
.{ .allow_delete = allow_delete },
|
||||
&diags,
|
||||
);
|
||||
}
|
||||
@@ -655,7 +654,7 @@ test "S7 case 10: a config.db stamped one version ahead is refused and left alon
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// case 11-19: export, import and bootstrap on real files
|
||||
// case 11-19: export and import on real files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "S7 case 11: an exported file is mode 0600 and starts with the header comment" {
|
||||
@@ -704,7 +703,7 @@ test "S7 case 12: export, import and export again are byte-identical files" {
|
||||
try testing.expectEqualStrings(a, b);
|
||||
}
|
||||
|
||||
test "S7 case 13: import refuses a configured database unless --force is given" {
|
||||
test "S7 case 13: import refuses a diff that deletes rows unless --allow-delete is given" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var f: Fixture = .init();
|
||||
@@ -716,7 +715,10 @@ test "S7 case 13: import refuses a configured database unless --force is given"
|
||||
defer data.deinit();
|
||||
try importInto(&f, &data, "first.zon", false);
|
||||
|
||||
try testing.expectError(error.DatabaseNotEmpty, importInto(&f, &data, "second.zon", false));
|
||||
// The two files name different upstream urls, and a url is the upstream's
|
||||
// identity: applying the second deletes the first's row, which is what the
|
||||
// gate exists to stop.
|
||||
try testing.expectError(error.DestructiveImport, importInto(&f, &data, "second.zon", false));
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
try data.database.queryInt(
|
||||
@@ -753,14 +755,15 @@ test "S7 case 14: an invalid import reports every problem and writes nothing" {
|
||||
&data.database,
|
||||
f.tmp.dir,
|
||||
"config.zon",
|
||||
.{ .force = false },
|
||||
.{ .allow_delete = false },
|
||||
&diags,
|
||||
)) |_| {
|
||||
return error.TestUnexpectedResult;
|
||||
} else |_| {}
|
||||
|
||||
try testing.expectEqual(@as(usize, 2), diags.problems.items.len);
|
||||
try testing.expect(try import.isEmpty(&data.database));
|
||||
try testing.expectEqual(@as(i64, 0), try data.database.queryInt("SELECT count(*) FROM upstreams"));
|
||||
try testing.expectEqual(@as(i64, 0), try data.database.queryInt("SELECT count(*) FROM settings"));
|
||||
|
||||
// Nothing beyond the database and its sidecars was created.
|
||||
var dir = try f.tmp.dir.openDir(io, "data", .{ .iterate = true });
|
||||
@@ -771,129 +774,6 @@ test "S7 case 14: an invalid import reports every problem and writes nothing" {
|
||||
}
|
||||
}
|
||||
|
||||
test "S7 case 15: bootstrap with no configuration file leaves the database empty" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var f: Fixture = .init();
|
||||
defer f.deinit();
|
||||
|
||||
var data = try openMigrated(&f, "data");
|
||||
defer data.deinit();
|
||||
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
|
||||
const outcome = try bootstrap.bootstrap(
|
||||
io,
|
||||
testing.allocator,
|
||||
&data.database,
|
||||
f.tmp.dir,
|
||||
"config.zon",
|
||||
&diags,
|
||||
);
|
||||
try testing.expectEqual(bootstrap.Outcome.no_config_file, outcome);
|
||||
try testing.expect(try import.isEmpty(&data.database));
|
||||
}
|
||||
|
||||
test "S7 case 16: bootstrap seeds an empty database from the configuration file" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var f: Fixture = .init();
|
||||
defer f.deinit();
|
||||
try f.write("config.zon", rich_config);
|
||||
|
||||
var data = try openMigrated(&f, "data");
|
||||
defer data.deinit();
|
||||
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
|
||||
const outcome = try bootstrap.bootstrap(
|
||||
io,
|
||||
testing.allocator,
|
||||
&data.database,
|
||||
f.tmp.dir,
|
||||
"config.zon",
|
||||
&diags,
|
||||
);
|
||||
try testing.expectEqual(bootstrap.Outcome.seeded, outcome);
|
||||
try testing.expectEqual(@as(i64, 2), try data.database.queryInt("SELECT count(*) FROM groups"));
|
||||
try testing.expectEqual(@as(i64, 2), try data.database.queryInt("SELECT count(*) FROM upstreams"));
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
try data.database.queryInt("SELECT count(*) FROM clients WHERE ip = 'fd00::1'"),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(i64, 5353),
|
||||
try data.database.queryInt("SELECT CAST(value AS INTEGER) FROM settings WHERE key = 'dns.port'"),
|
||||
);
|
||||
}
|
||||
|
||||
test "S7 case 17: bootstrap on a configured database never reads the file" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var f: Fixture = .init();
|
||||
defer f.deinit();
|
||||
try f.write("seed.zon", minimal_config);
|
||||
|
||||
var data = try openMigrated(&f, "data");
|
||||
defer data.deinit();
|
||||
try importInto(&f, &data, "seed.zon", false);
|
||||
|
||||
// Unparseable on purpose: the call can only succeed if the file is never
|
||||
// opened.
|
||||
try f.write("config.zon", broken_zon);
|
||||
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
|
||||
const outcome = try bootstrap.bootstrap(
|
||||
io,
|
||||
testing.allocator,
|
||||
&data.database,
|
||||
f.tmp.dir,
|
||||
"config.zon",
|
||||
&diags,
|
||||
);
|
||||
try testing.expectEqual(bootstrap.Outcome.db_already_configured, outcome);
|
||||
try testing.expectEqual(@as(usize, 0), diags.problems.items.len);
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
try data.database.queryInt(
|
||||
"SELECT count(*) FROM upstreams WHERE url = 'https://dns.example/dns-query'",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test "S7 case 18: bootstrap with an invalid configuration file fails and writes nothing" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var f: Fixture = .init();
|
||||
defer f.deinit();
|
||||
try f.write("config.zon", two_problem_config);
|
||||
|
||||
var data = try openMigrated(&f, "data");
|
||||
defer data.deinit();
|
||||
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
|
||||
if (bootstrap.bootstrap(
|
||||
io,
|
||||
testing.allocator,
|
||||
&data.database,
|
||||
f.tmp.dir,
|
||||
"config.zon",
|
||||
&diags,
|
||||
)) |outcome| {
|
||||
std.debug.print("bootstrap unexpectedly returned .{s}\n", .{@tagName(outcome)});
|
||||
return error.TestUnexpectedResult;
|
||||
} else |_| {}
|
||||
|
||||
try testing.expectEqual(@as(usize, 2), diags.problems.items.len);
|
||||
try testing.expect(try import.isEmpty(&data.database));
|
||||
}
|
||||
|
||||
test "S7 case 19: writeToFile replaces an existing file and restores mode 0600" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
@@ -988,11 +868,13 @@ test "S7 case 21: runCheck passes a seeded database and reports two stored probl
|
||||
try importInto(&f, &data, "config.zon", false);
|
||||
}
|
||||
{
|
||||
// `applyToDb` rather than an import: the validator would refuse this
|
||||
// `apply` rather than an import: the validator would refuse this
|
||||
// configuration, and the case needs the problems to reach the database.
|
||||
var data = try openMigrated(&f, "bad");
|
||||
defer data.deinit();
|
||||
try import.applyToDb(io, testing.allocator, &data.database, two_problem_model, 42, .{});
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
try import.apply(io, testing.allocator, &data.database, two_problem_model, 42, .{}, &diags);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -1045,13 +927,16 @@ test "S7 case 22: runCheck probes a real upstream and prints an OK line" {
|
||||
|
||||
const code = cli.runCheck(
|
||||
captured.runner(),
|
||||
.{ .paths = .{ .config = config_path }, .config_explicit = true },
|
||||
.{ .config = config_path },
|
||||
true,
|
||||
);
|
||||
try testing.expectEqual(cli.exit_ok, code);
|
||||
// Milestone 13 changed the probe line to the redacted `OK upstreams[i]`
|
||||
// form; this expectation went stale unnoticed because nothing ran -Dlive
|
||||
// between then and milestone 20.
|
||||
try testing.expect(std.mem.count(
|
||||
u8,
|
||||
captured.out.written(),
|
||||
"OK https://cloudflare-dns.com/dns-query\n",
|
||||
"OK upstreams[0] https://cloudflare-dns.com\n",
|
||||
) == 1);
|
||||
}
|
||||
|
||||
+2
-1
@@ -49,7 +49,8 @@ comptime {
|
||||
_ = @import("storage/repositories/settings_repo.zig");
|
||||
_ = @import("config/export.zig");
|
||||
_ = @import("config/import.zig");
|
||||
_ = @import("config/bootstrap.zig");
|
||||
_ = @import("config/loader.zig");
|
||||
_ = @import("config/reconcile.zig");
|
||||
_ = @import("cli.zig");
|
||||
_ = @import("storage/storage_integration_test.zig");
|
||||
_ = @import("filter/parsers.zig");
|
||||
|
||||
+6
-2
@@ -58,9 +58,12 @@ pub const cookie_attributes = "HttpOnly; SameSite=Lax; Path=/";
|
||||
pub const max_password_len = 256;
|
||||
|
||||
/// Authentication is on exactly when a hash exists (ruling 17). An empty hash
|
||||
/// is the documented "no password set" state, not a misconfiguration.
|
||||
/// is the documented "no password set" state, not a misconfiguration; a null
|
||||
/// one means the settings table holds no hash row at all, which is the same
|
||||
/// answer.
|
||||
pub fn authEnabled(web: model.Web) bool {
|
||||
return web.password_hash.len != 0;
|
||||
const hash = web.password_hash orelse return false;
|
||||
return hash.len != 0;
|
||||
}
|
||||
|
||||
pub const Outcome = enum {
|
||||
@@ -453,6 +456,7 @@ fn tokenOf(n: u8) [token_bytes]u8 {
|
||||
|
||||
test "authEnabled follows the presence of a hash" {
|
||||
try testing.expect(!authEnabled(.{}));
|
||||
try testing.expect(!authEnabled(.{ .password_hash = null }));
|
||||
try testing.expect(!authEnabled(.{ .password_hash = "" }));
|
||||
try testing.expect(authEnabled(.{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" }));
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const Allocator = std.mem.Allocator;
|
||||
|
||||
const address = @import("../../platform/address.zig");
|
||||
const clients_repo = @import("../../storage/repositories/clients_repo.zig");
|
||||
const db = @import("../../storage/db.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const mutations = @import("mutations.zig");
|
||||
@@ -155,7 +156,76 @@ const resource = mutations.Resource(.{
|
||||
|
||||
pub const list = resource.list;
|
||||
pub const get = resource.get;
|
||||
pub const remove = resource.remove;
|
||||
|
||||
/// What file authority found when it went to delete a row.
|
||||
pub const ObservedDelete = enum { deleted, declared, absent };
|
||||
|
||||
/// Reads `hand_edited` and acts on it inside one `BEGIN IMMEDIATE`, because the
|
||||
/// two halves are a single decision. Split across two statements, a concurrent
|
||||
/// `nxdns import` — which takes the same write lock for its own reconcile — can
|
||||
/// promote the row between the read and the DELETE, and file authority would
|
||||
/// delete a client the file had just declared. Holding the write lock across
|
||||
/// both makes the promotion wait, and it then sees the row already gone or
|
||||
/// still there, never half of each.
|
||||
///
|
||||
/// A read-only outcome commits an empty transaction, which costs nothing and
|
||||
/// keeps the one exit path.
|
||||
fn deleteIfObserved(database: *db.Db, arena: Allocator, id: i64) db.Error!ObservedDelete {
|
||||
var tx = try db.Tx.begin(database);
|
||||
errdefer tx.rollback();
|
||||
|
||||
const row = try clients_repo.getClient(database, arena, id);
|
||||
const verdict: ObservedDelete = if (row) |found|
|
||||
(if (found.hand_edited) .declared else .deleted)
|
||||
else
|
||||
.absent;
|
||||
|
||||
if (verdict == .deleted) try clients_repo.deleteClient(database, id);
|
||||
try tx.commit();
|
||||
return verdict;
|
||||
}
|
||||
|
||||
/// DELETE is a `runtime_action` in the route table (milestone-20 ruling 7), so
|
||||
/// file authority lets it through: an observed row is runtime state the file
|
||||
/// never declared, and without a way to remove it a mis-identified or departed
|
||||
/// device would be immortal — the file can promote an IP, never forget one.
|
||||
/// A row the file *declares* is configuration, and deleting it would contradict
|
||||
/// the file, so it answers the same 403 the router answers elsewhere. This is
|
||||
/// the one policy decision that needs a row read, which is why it is here and
|
||||
/// not a table column.
|
||||
///
|
||||
/// A row that is not there is a 404, exactly as in database mode: file
|
||||
/// authority must not turn a missing row into a policy verdict.
|
||||
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const path = switch (state.authority) {
|
||||
.database => return resource.remove(state, io, request),
|
||||
.managed_file => |managed| managed,
|
||||
};
|
||||
|
||||
const database = mutations.requireConfigDb(state) catch
|
||||
return mutations.respondFailure(request, mutations.no_config_db, delete_what);
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const outcome = deleteIfObserved(database, request.arena, request.id.?);
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
switch (outcome catch |err| return mutations.respondFailure(
|
||||
request,
|
||||
mutations.dbFailure(err, group_conflict),
|
||||
delete_what,
|
||||
)) {
|
||||
.absent => return mutations.respondFailure(request, .not_found, ""),
|
||||
.declared => return http_util.respondManagedByFile(request, path),
|
||||
.deleted => {},
|
||||
}
|
||||
|
||||
if (mutations.reload(state, io)) |failure| {
|
||||
return mutations.respondFailure(request, failure, delete_what);
|
||||
}
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
}
|
||||
|
||||
const delete_what = "deleting a client";
|
||||
|
||||
/// The prefixes are one list resource with no `/{id}` route: the whole set is
|
||||
/// read and replaced (ruling 9), so there is nothing to get or delete by id.
|
||||
@@ -280,6 +350,45 @@ test "deleting a client removes the row and announces the change" {
|
||||
try testing.expectEqual(@as(usize, 1), bench.reloads);
|
||||
}
|
||||
|
||||
test "file authority deletes an observed client and refuses a declared one" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seedClient(&bench);
|
||||
try bench.exec(
|
||||
\\INSERT INTO clients (id, ip, group_id, hand_edited, first_seen, last_seen)
|
||||
\\VALUES (2, '192.168.1.11', 1, 1, 100, 200);
|
||||
);
|
||||
|
||||
// The declared row is configuration; it survives, and nothing is written.
|
||||
try testing.expectEqual(ObservedDelete.declared, try deleteIfObserved(&bench.database, bench.arena(), 2));
|
||||
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 2"));
|
||||
|
||||
try testing.expectEqual(ObservedDelete.deleted, try deleteIfObserved(&bench.database, bench.arena(), 1));
|
||||
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 1"));
|
||||
|
||||
try testing.expectEqual(ObservedDelete.absent, try deleteIfObserved(&bench.database, bench.arena(), 999));
|
||||
}
|
||||
|
||||
test "the observed check and the delete are one transaction" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seedClient(&bench);
|
||||
|
||||
// SQLite refuses a `BEGIN IMMEDIATE` inside an open transaction, so a held
|
||||
// transaction is what proves this takes the write lock rather than reading
|
||||
// and deleting through two unsynchronised statements — the window a
|
||||
// concurrent `nxdns import` would promote the row in. Without the
|
||||
// transaction both statements run and the row is gone.
|
||||
var tx = try db.Tx.begin(&bench.database);
|
||||
try testing.expectError(error.Unexpected, deleteIfObserved(&bench.database, bench.arena(), 1));
|
||||
tx.rollback();
|
||||
|
||||
// The row is untouched: the refusal happened before any statement ran.
|
||||
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 1"));
|
||||
}
|
||||
|
||||
test "the prefix list is replaced whole" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
|
||||
@@ -125,9 +125,15 @@ fn Partial(comptime Section: type, comptime section_name: []const u8) type {
|
||||
|
||||
/// Enums arrive as the words the database stores, so they are parsed from text
|
||||
/// rather than by tag name (`logging.level` is `error`, whose tag cannot be).
|
||||
///
|
||||
/// An optional model field collapses to its child, because `Partial` wraps
|
||||
/// every field in one optional of its own and that optional already carries the
|
||||
/// only meaning a PUT has for absence — "leave it". A double optional would be
|
||||
/// two ways to say the same thing, and `std.json` cannot parse the outer one.
|
||||
fn FieldType(comptime T: type) type {
|
||||
return switch (@typeInfo(T)) {
|
||||
.@"enum" => []const u8,
|
||||
.optional => |info| FieldType(info.child),
|
||||
else => T,
|
||||
};
|
||||
}
|
||||
@@ -321,16 +327,17 @@ pub fn applyPut(
|
||||
|
||||
// The password never becomes a row: the hash made above is what the merged
|
||||
// configuration — and therefore the settings table — carries.
|
||||
const previous_hash = cfg.web.password_hash;
|
||||
const previous_hash = cfg.web.password_hash orelse "";
|
||||
if (password != null) cfg.web.password_hash = new_hash;
|
||||
cfg.web.password = "";
|
||||
cfg.web.password = null;
|
||||
|
||||
if (try problem(arena, cfg)) |text| return .{ .fail = .{ .invalid = text } };
|
||||
|
||||
// The gpa copy the live holder will own, made before the write so a
|
||||
// committed transaction can never be followed by a failed revocation.
|
||||
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, cfg.web.password_hash);
|
||||
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, cfg.web.password_hash) else null;
|
||||
const merged_hash = cfg.web.password_hash orelse "";
|
||||
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, merged_hash);
|
||||
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, merged_hash) else null;
|
||||
|
||||
writeSettings(arena, database, cfg) catch |err| {
|
||||
if (replacement) |hash| state.gpa.free(hash);
|
||||
@@ -365,6 +372,12 @@ fn writeSettings(arena: Allocator, database: *db.Db, cfg: model.Config) db.Error
|
||||
for (pairs.items) |pair| {
|
||||
try settings_repo.putSetting(database, pair.key, pair.value);
|
||||
}
|
||||
// `toSettings` stops at `web.password_hash` (ruling 4 of milestone 20: the
|
||||
// reconcile engine owns that row, because only it can tell "the file said
|
||||
// nothing" from "the file said empty"). A PUT has no such ambiguity — the
|
||||
// merged configuration is the whole truth — so this handler writes the row
|
||||
// itself rather than losing the password change.
|
||||
try settings_repo.putSetting(database, "web.password_hash", cfg.web.password_hash orelse "");
|
||||
try tx.commit();
|
||||
}
|
||||
|
||||
@@ -455,6 +468,36 @@ pub const hash_stall_control = if (builtin.is_test) struct {
|
||||
// routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Which source governs this process's configuration, and when it last read
|
||||
/// it (milestone-20 ruling 7). This is how the UI learns that configuration is
|
||||
/// read-only — declaratively, rather than by probing a route for a 403.
|
||||
///
|
||||
/// It rides `GET /api/settings` because that route needs a session: the
|
||||
/// managed path is a filesystem path and must never reach the open
|
||||
/// `/api/version` or `/api/health`.
|
||||
///
|
||||
/// `reconciled_at` means exactly "this process loaded the file at T". A file
|
||||
/// whose mtime is newer has not been loaded by the running process. It cannot
|
||||
/// answer "is the file what the server uses" — a stepped clock or a preserved
|
||||
/// mtime defeats the comparison in either direction, and the database can move
|
||||
/// under `nxdns import` without either timestamp moving.
|
||||
const AuthorityView = struct {
|
||||
mode: []const u8,
|
||||
path: ?[]const u8,
|
||||
reconciled_at: ?i64,
|
||||
};
|
||||
|
||||
fn authorityView(state: *const server.WebState) AuthorityView {
|
||||
return switch (state.authority) {
|
||||
.database => .{ .mode = "database", .path = null, .reconciled_at = state.reconciled_at },
|
||||
.managed_file => |path| .{
|
||||
.mode = "managed_file",
|
||||
.path = path,
|
||||
.reconciled_at = state.reconciled_at,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const database = mutations.requireConfigDb(state) catch
|
||||
return mutations.respondFailure(request, mutations.no_config_db, "reading the settings");
|
||||
@@ -470,7 +513,7 @@ pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
|
||||
const cfg = loaded catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading the settings");
|
||||
|
||||
return respondSettings(request, .ok, cfg);
|
||||
return respondSettings(request, state, .ok, cfg);
|
||||
}
|
||||
|
||||
pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
@@ -479,14 +522,20 @@ pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
|
||||
|
||||
return switch (try applyPut(state, io, request.arena, parsed.value)) {
|
||||
.fail => |failure| mutations.respondFailure(request, failure, "writing the settings"),
|
||||
.config => |cfg| respondSettings(request, .ok, cfg),
|
||||
.config => |cfg| respondSettings(request, state, .ok, cfg),
|
||||
};
|
||||
}
|
||||
|
||||
fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config) HandlerError!void {
|
||||
fn respondSettings(
|
||||
request: *Request,
|
||||
state: *const server.WebState,
|
||||
status: std.http.Status,
|
||||
cfg: model.Config,
|
||||
) HandlerError!void {
|
||||
return http_util.respondJson(request, status, .{
|
||||
.settings = view(cfg),
|
||||
.restart_required = restart_required_keys,
|
||||
.authority = authorityView(state),
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
@@ -498,8 +547,10 @@ const testing = std.testing;
|
||||
const auth_handlers = @import("auth.zig");
|
||||
|
||||
test "the restart-required table lists every settings key and no secret" {
|
||||
// `model.toSettings` is the other half of the same fact: the keys the
|
||||
// database stores, minus the hash the API never serializes.
|
||||
// `model.toSettings` is the other half of the same fact. The two lists are
|
||||
// now equal rather than off by one: `toSettings` stopped emitting
|
||||
// `web.password_hash` (milestone 20 ruling 4) and this table never listed
|
||||
// it, so both exclude the hash and the plaintext.
|
||||
var pairs: std.ArrayList(model.SettingPair) = .empty;
|
||||
defer {
|
||||
model.freeSettings(testing.allocator, pairs.items);
|
||||
@@ -507,7 +558,7 @@ test "the restart-required table lists every settings key and no secret" {
|
||||
}
|
||||
try model.toSettings(.{}, testing.allocator, &pairs);
|
||||
|
||||
try testing.expectEqual(pairs.items.len - 1, restart_required_keys.len);
|
||||
try testing.expectEqual(pairs.items.len, restart_required_keys.len);
|
||||
for (restart_required_keys) |key| {
|
||||
try testing.expect(!std.mem.eql(u8, key, "web.password_hash"));
|
||||
try testing.expect(!std.mem.eql(u8, key, "web.password"));
|
||||
@@ -641,7 +692,7 @@ test "a new password is stored as a hash and ends every session" {
|
||||
patch.web = .{ .password = "correct horse battery staple" };
|
||||
|
||||
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
|
||||
try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash, "$argon2id$"));
|
||||
try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash.?, "$argon2id$"));
|
||||
try testing.expect(!sessions.validateAt(bench.io(), &cookie, 1_001));
|
||||
|
||||
// The plain password is nowhere in the table, and the hash is.
|
||||
@@ -650,10 +701,10 @@ test "a new password is stored as a hash and ends every session" {
|
||||
try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.password'"),
|
||||
);
|
||||
const stored = try mutations.loadConfig(bench.arena(), &bench.database);
|
||||
try testing.expect(std.mem.startsWith(u8, stored.web.password_hash, "$argon2id$"));
|
||||
try testing.expect(std.mem.startsWith(u8, stored.web.password_hash.?, "$argon2id$"));
|
||||
try testing.expectEqual(
|
||||
auth.Outcome.ok,
|
||||
try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash, "correct horse battery staple"),
|
||||
try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash.?, "correct horse battery staple"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -713,7 +764,7 @@ test "an empty password is not a password change" {
|
||||
patch.web = .{ .password = "" };
|
||||
|
||||
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
|
||||
try testing.expectEqualStrings("", outcome.config.web.password_hash);
|
||||
try testing.expectEqualStrings("", outcome.config.web.password_hash orelse "");
|
||||
}
|
||||
|
||||
test "reading the settings with no database is unavailable" {
|
||||
|
||||
+25
-10
@@ -307,23 +307,38 @@ pub fn parseBody(comptime T: type, request: *Request) (BodyError || error{BadJso
|
||||
|
||||
/// Ruling 8's envelope. `message` is operator-facing text, never a raw internal
|
||||
/// error string for a 500 (PLAN §19: details go to the log, not the wire).
|
||||
///
|
||||
/// Built on the request arena, like `respondJson` below. It used to build into
|
||||
/// a 512-byte stack buffer and fall back to `text/plain` when the message
|
||||
/// overflowed it, which made the documented JSON envelope a function of message
|
||||
/// length — a long managed-file path (milestone-20 ruling 7) was enough to
|
||||
/// demote it. The envelope is `application/json` at every length now.
|
||||
pub fn respondError(
|
||||
request: *Request,
|
||||
status: http.Status,
|
||||
message: []const u8,
|
||||
) HandlerError!void {
|
||||
var buf: [512]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
var stringify: std.json.Stringify = .{ .writer = &writer };
|
||||
stringify.beginObject() catch return respondPlain(request, status, message);
|
||||
stringify.objectField("error") catch return respondPlain(request, status, message);
|
||||
stringify.write(message) catch return respondPlain(request, status, message);
|
||||
stringify.endObject() catch return respondPlain(request, status, message);
|
||||
return respondBytes(request, status, writer.buffered(), content_type_json, &.{});
|
||||
var allocating: std.Io.Writer.Allocating = .init(request.arena);
|
||||
defer allocating.deinit();
|
||||
var stringify: std.json.Stringify = .{ .writer = &allocating.writer };
|
||||
stringify.beginObject() catch return error.OutOfMemory;
|
||||
stringify.objectField("error") catch return error.OutOfMemory;
|
||||
stringify.write(message) catch return error.OutOfMemory;
|
||||
stringify.endObject() catch return error.OutOfMemory;
|
||||
return respondBytes(request, status, allocating.written(), content_type_json, &.{});
|
||||
}
|
||||
|
||||
fn respondPlain(request: *Request, status: http.Status, message: []const u8) HandlerError!void {
|
||||
return respondBytes(request, status, message, content_type_text, &.{});
|
||||
/// Milestone-20 ruling 7's rejection: a configuration write under file
|
||||
/// authority. One function, because the router rejects most of them and the
|
||||
/// clients handler rejects the one that needs a row read — two wordings would
|
||||
/// be two contracts.
|
||||
pub fn respondManagedByFile(request: *Request, path: []const u8) HandlerError!void {
|
||||
const message = try std.fmt.allocPrint(
|
||||
request.arena,
|
||||
"configuration is managed by {s}; edit the file and restart",
|
||||
.{path},
|
||||
);
|
||||
return respondError(request, .forbidden, message);
|
||||
}
|
||||
|
||||
/// Serialises `value` and responds. The document is built in the request arena
|
||||
|
||||
+99
-1
@@ -29,6 +29,15 @@ info:
|
||||
- Mutations to groups, blocklists, rules, local records, forward zones,
|
||||
clients and client prefixes take effect live. Upstreams and
|
||||
`/api/settings` are restart-required.
|
||||
- nxdns runs under one of two configuration authorities. Started with
|
||||
`--config=<file>`, that file is the sole declarative source, and every
|
||||
operation that writes configuration answers 403 with the same error
|
||||
envelope, naming the file. Operations that change runtime state —
|
||||
`/api/pause`, `POST /api/blocklists/update`, `/api/certs/reload`, the
|
||||
login and the logout — stay live, as does `DELETE /api/clients/{id}`
|
||||
for a client the file does not declare. `GET /api/settings` reports
|
||||
the live authority, so a client reads the mode rather than
|
||||
discovering it from a rejection.
|
||||
|
||||
servers:
|
||||
- url: /
|
||||
@@ -391,6 +400,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"413":
|
||||
@@ -444,6 +455,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -466,6 +479,8 @@ paths:
|
||||
description: Deleted; applied live.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -519,6 +534,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -575,6 +592,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"413":
|
||||
@@ -655,6 +674,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -674,6 +695,8 @@ paths:
|
||||
description: Deleted; applied live.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -728,6 +751,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"413":
|
||||
@@ -780,6 +805,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -799,6 +826,8 @@ paths:
|
||||
description: Deleted; applied live.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -853,6 +882,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"413":
|
||||
@@ -905,6 +936,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -924,6 +957,8 @@ paths:
|
||||
description: Deleted; applied live.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -978,6 +1013,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"413":
|
||||
@@ -1030,6 +1067,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -1049,6 +1088,8 @@ paths:
|
||||
description: Deleted; applied live.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -1133,6 +1174,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -1222,6 +1265,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"413":
|
||||
@@ -1277,6 +1322,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"413":
|
||||
@@ -1330,6 +1377,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -1350,6 +1399,8 @@ paths:
|
||||
description: Deleted; takes effect on restart.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -1454,6 +1505,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"413":
|
||||
$ref: "#/components/responses/BodyTooLarge"
|
||||
"429":
|
||||
@@ -1526,6 +1579,16 @@ components:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
ManagedByFile:
|
||||
description: |
|
||||
nxdns is running under file authority and this operation writes
|
||||
configuration. The message names the file. Authentication is checked
|
||||
first, so an unauthenticated request to a protected route still
|
||||
answers 401 rather than disclosing that the route exists.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
NotFound:
|
||||
description: No row has this id.
|
||||
content:
|
||||
@@ -2193,7 +2256,7 @@ components:
|
||||
|
||||
SettingsEnvelope:
|
||||
type: object
|
||||
required: [settings, restart_required]
|
||||
required: [settings, restart_required, authority]
|
||||
properties:
|
||||
settings:
|
||||
$ref: "#/components/schemas/Settings"
|
||||
@@ -2203,6 +2266,41 @@ components:
|
||||
description: |
|
||||
Every `section.field` key that needs a restart to take effect —
|
||||
currently all of them.
|
||||
authority:
|
||||
$ref: "#/components/schemas/Authority"
|
||||
|
||||
Authority:
|
||||
type: object
|
||||
description: |
|
||||
Which source governs this process's configuration. This is how a
|
||||
client learns that configuration is read-only; it never has to probe
|
||||
a write route for a 403. The block rides this authenticated endpoint
|
||||
because `path` is a filesystem path, and never appears on the open
|
||||
`/api/version` or `/api/health`.
|
||||
required: [mode, path, reconciled_at]
|
||||
properties:
|
||||
mode:
|
||||
type: string
|
||||
enum: [database, managed_file]
|
||||
description: |
|
||||
`database` when nxdns runs without `--config`; `managed_file`
|
||||
when it runs with it, in which case every configuration write
|
||||
answers 403.
|
||||
path:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The managed file, or null in `database` mode.
|
||||
reconciled_at:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: |
|
||||
When this process loaded the managed file, in epoch seconds, and
|
||||
null in `database` mode. It means exactly that: a file whose
|
||||
mtime is newer has not been loaded by the running process. It
|
||||
cannot answer whether the file matches what the server serves —
|
||||
a stepped clock or a preserved mtime defeats the comparison
|
||||
either way, and `nxdns import` can move the database without
|
||||
moving either timestamp.
|
||||
|
||||
SettingsPatch:
|
||||
type: object
|
||||
|
||||
@@ -48,6 +48,84 @@ test "every served route appears textually in the document" {
|
||||
}
|
||||
}
|
||||
|
||||
// Drift guard for milestone-20 ruling 7: a route classified `config_write` can
|
||||
// answer 403 under file authority, so its operation must say so — and a route
|
||||
// that cannot must not claim it. Textual, like the coverage test above: the
|
||||
// document has no parser here, and the two facts it compares are one line each.
|
||||
test "every config write documents the file-authority 403, and nothing else does" {
|
||||
for (router.routes) |route| {
|
||||
const operation = try operationBlock(route.pattern, route.method);
|
||||
const documented = std.mem.containsAtLeast(u8, operation, 1, "\n \"403\":\n");
|
||||
if (documented != (route.policy == .config_write)) {
|
||||
std.debug.print(
|
||||
"{t} {s} is {t} but {s} a 403\n",
|
||||
.{ route.method, route.pattern, route.policy, if (documented) "documents" else "does not document" },
|
||||
);
|
||||
return error.TestUnexpectedResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The body of one operation: everything under `pattern`'s `method` key.
|
||||
///
|
||||
/// The path block is bounded *before* the method is looked for. Searching the
|
||||
/// rest of the document instead would let a later path's `delete:` answer for a
|
||||
/// path that has none, and the guard above would pass on an operation nobody
|
||||
/// documented.
|
||||
fn operationBlock(pattern: []const u8, method: std.http.Method) ![]const u8 {
|
||||
var key_buf: [128]u8 = undefined;
|
||||
const path_key = try std.fmt.bufPrint(&key_buf, "\n {s}:\n", .{pattern});
|
||||
const path_at = std.mem.indexOf(u8, yaml, path_key) orelse return error.PathNotDocumented;
|
||||
const path_body = blockUnder(yaml[path_at + path_key.len ..], 2);
|
||||
|
||||
var method_buf: [16]u8 = undefined;
|
||||
const method_key = try std.fmt.bufPrint(&method_buf, " {s}:\n", .{@tagName(method)});
|
||||
_ = std.ascii.lowerString(&method_buf, method_key);
|
||||
const key = method_buf[0..method_key.len];
|
||||
|
||||
// Anchored at a line start: a `get:` nested deeper inside a description
|
||||
// contains the four-space key as a substring.
|
||||
var offset: usize = 0;
|
||||
while (offset < path_body.len) {
|
||||
if (std.mem.startsWith(u8, path_body[offset..], key)) {
|
||||
return blockUnder(path_body[offset + key.len ..], 4);
|
||||
}
|
||||
offset = (std.mem.indexOfScalarPos(u8, path_body, offset, '\n') orelse path_body.len) + 1;
|
||||
}
|
||||
return error.MethodNotDocumented;
|
||||
}
|
||||
|
||||
/// The run of lines at the start of `body` indented deeper than `indent` — what
|
||||
/// belongs to the key that just ended. `body` starts at a line boundary. Blank
|
||||
/// lines belong to whatever surrounds them and never close a block.
|
||||
fn blockUnder(body: []const u8, indent: usize) []const u8 {
|
||||
var offset: usize = 0;
|
||||
while (offset < body.len) {
|
||||
const line_end = std.mem.indexOfScalarPos(u8, body, offset, '\n') orelse body.len;
|
||||
if (line_end != offset) {
|
||||
const depth = for (body[offset..line_end], 0..) |c, i| {
|
||||
if (c != ' ') break i;
|
||||
} else line_end - offset;
|
||||
if (depth <= indent) return body[0..offset];
|
||||
}
|
||||
offset = line_end + 1;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
test "an operation block stops at its own path and its own method" {
|
||||
// `/api/groups` has no DELETE. An unbounded search answers with the one
|
||||
// under `/api/groups/{id}`, and the 403 guard then grades the wrong
|
||||
// operation — silently passing for a route nobody documented.
|
||||
try testing.expectError(error.MethodNotDocumented, operationBlock("/api/groups", .DELETE));
|
||||
|
||||
// A block it does have never reaches into its neighbour under the same
|
||||
// path either.
|
||||
const list_groups = try operationBlock("/api/groups", .GET);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, list_groups, 1, "List groups"));
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, list_groups, 1, "Create a group"));
|
||||
}
|
||||
|
||||
test "the document names the contract's fixed points" {
|
||||
for ([_][]const u8{
|
||||
"openapi: 3.0.3",
|
||||
|
||||
+55
-7
@@ -37,12 +37,20 @@ pub const Auth = enum { open, session };
|
||||
/// monitoring endpoints so a Prometheus scrape can never be throttled.
|
||||
pub const RateLimit = enum { counted, exempt };
|
||||
|
||||
/// What a route does to the configuration, and therefore whether file
|
||||
/// authority may allow it (milestone-20 ruling 7). `config_write` changes the
|
||||
/// declarative state the managed file owns; `runtime_action` changes runtime
|
||||
/// state the file never declares; `read` changes nothing.
|
||||
pub const Policy = enum { read, config_write, runtime_action };
|
||||
|
||||
pub const RouteInfo = struct {
|
||||
method: http.Method,
|
||||
/// Segments separated by `/`, with at most one `{id}` capture, which must
|
||||
/// be a positive integer row id.
|
||||
pattern: []const u8,
|
||||
auth: Auth,
|
||||
/// No default: a new route states its class or does not compile.
|
||||
policy: Policy,
|
||||
handler: HandlerFn,
|
||||
rate_limit: RateLimit = .counted,
|
||||
};
|
||||
@@ -158,6 +166,17 @@ pub fn dispatch(
|
||||
return http_util.respondError(request, .unauthorized, "authentication required");
|
||||
}
|
||||
|
||||
// Milestone-20 ruling 7, and it runs *after* the auth check on purpose:
|
||||
// rejecting before authenticating would tell an anonymous caller which
|
||||
// routes exist. An unauthenticated request to a protected route answers
|
||||
// 401 in both authority modes.
|
||||
if (found.route.policy == .config_write) {
|
||||
switch (state.authority) {
|
||||
.database => {},
|
||||
.managed_file => |path| return http_util.respondManagedByFile(request, path),
|
||||
}
|
||||
}
|
||||
|
||||
return found.route.handler(state, io, request);
|
||||
}
|
||||
|
||||
@@ -196,13 +215,14 @@ fn noopHandler(
|
||||
}
|
||||
|
||||
const test_table = [_]RouteInfo{
|
||||
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = noopHandler, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = noopHandler },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = noopHandler },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
|
||||
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = noopHandler },
|
||||
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = noopHandler, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = noopHandler },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = noopHandler },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = noopHandler },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
|
||||
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .handler = noopHandler },
|
||||
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .handler = noopHandler },
|
||||
};
|
||||
|
||||
fn matchPath(method: http.Method, path: []const u8) Match {
|
||||
@@ -263,6 +283,34 @@ test "the allow header lists every method the path accepts" {
|
||||
try testing.expectEqualStrings("GET, PUT, DELETE", formatAllow(&test_table, item.segments(), &buf));
|
||||
}
|
||||
|
||||
test "matching carries the class the table declares, per route and not per prefix" {
|
||||
const cases = [_]struct { method: http.Method, path: []const u8, policy: Policy }{
|
||||
.{ .method = .GET, .path = "/api/groups", .policy = .read },
|
||||
.{ .method = .GET, .path = "/api/groups/7", .policy = .read },
|
||||
.{ .method = .POST, .path = "/api/groups", .policy = .config_write },
|
||||
.{ .method = .PUT, .path = "/api/groups/7", .policy = .config_write },
|
||||
.{ .method = .DELETE, .path = "/api/groups/7", .policy = .config_write },
|
||||
.{ .method = .PUT, .path = "/api/groups/7/sources", .policy = .config_write },
|
||||
// Same prefix, different class: the column is per route.
|
||||
.{ .method = .POST, .path = "/api/pause", .policy = .runtime_action },
|
||||
};
|
||||
for (cases) |case| {
|
||||
try testing.expectEqual(case.policy, matchPath(case.method, case.path).found.route.policy);
|
||||
}
|
||||
}
|
||||
|
||||
test "the shipped route table classifies /api/blocklists by route, not by prefix" {
|
||||
var refresh: ?Policy = null;
|
||||
var create: ?Policy = null;
|
||||
for (routes) |route| {
|
||||
if (route.method != .POST) continue;
|
||||
if (std.mem.eql(u8, route.pattern, "/api/blocklists/update")) refresh = route.policy;
|
||||
if (std.mem.eql(u8, route.pattern, "/api/blocklists")) create = route.policy;
|
||||
}
|
||||
try testing.expectEqual(Policy.runtime_action, refresh.?);
|
||||
try testing.expectEqual(Policy.config_write, create.?);
|
||||
}
|
||||
|
||||
test "the shipped route table is the one the router matches against" {
|
||||
try testing.expectEqual(routes_table.table.ptr, routes.ptr);
|
||||
try testing.expectEqual(routes_table.table.len, routes.len);
|
||||
|
||||
+132
-56
@@ -18,6 +18,17 @@
|
||||
//! bucket. The static assets are ruling 18's remaining exemption; they are
|
||||
//! not routes — the router sends unmatched non-`/api` paths to
|
||||
//! `WebState.fallback` before any policy check.
|
||||
//!
|
||||
//! `policy` is the third such column, and milestone-20 ruling 7's contract:
|
||||
//! under file authority the file is the sole declarative source, so a
|
||||
//! `config_write` answers 403 and a `runtime_action` stays live. It has no
|
||||
//! default value on purpose — a route added without a stated class must not
|
||||
//! inherit one. Classification is per route, not per prefix:
|
||||
//! `POST /api/blocklists/update` is a refresh, a `runtime_action`, while its
|
||||
//! CRUD siblings write configuration. `DELETE /api/clients/{id}` is a
|
||||
//! `runtime_action` here because deleting an *observed* row discards runtime
|
||||
//! state the file never declared; the declared case needs a row read and the
|
||||
//! clients handler answers it.
|
||||
|
||||
const router = @import("router.zig");
|
||||
|
||||
@@ -43,85 +54,85 @@ const version = @import("handlers/version.zig");
|
||||
|
||||
pub const table: []const router.RouteInfo = &.{
|
||||
// Monitoring and contract (ruling 18's open set, ruling 19's exemptions).
|
||||
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .handler = metrics.handle, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = health.handle, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .handler = version.handle },
|
||||
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .handler = openapi.handle },
|
||||
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .policy = .read, .handler = metrics.handle, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = health.handle, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .policy = .read, .handler = version.handle },
|
||||
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .policy = .read, .handler = openapi.handle },
|
||||
|
||||
// Authentication.
|
||||
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .handler = auth.login },
|
||||
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .handler = auth.logout },
|
||||
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .policy = .runtime_action, .handler = auth.login },
|
||||
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .policy = .runtime_action, .handler = auth.logout },
|
||||
|
||||
// Query log, stats, live stream, lookup.
|
||||
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .handler = queries.list },
|
||||
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .handler = live.stream, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .handler = stats.totals },
|
||||
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .handler = stats.timeseries },
|
||||
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .handler = lookup.handle },
|
||||
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .handler = upstream_health.handle },
|
||||
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .handler = queries.list },
|
||||
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .handler = live.stream, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .handler = stats.totals },
|
||||
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .handler = stats.timeseries },
|
||||
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .handler = lookup.handle },
|
||||
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .handler = upstream_health.handle },
|
||||
|
||||
// Groups.
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = groups.list },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = groups.create },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.get },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.remove },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.getSources },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.putSources },
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = groups.list },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = groups.create },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = groups.get },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = groups.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = groups.remove },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .read, .handler = groups.getSources },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .handler = groups.putSources },
|
||||
|
||||
// Blocklist sources. `/api/blocklists/update` is a literal segment; it
|
||||
// cannot collide with `{id}`, which only matches a positive integer.
|
||||
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.list },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.create },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .handler = blocklists.refresh },
|
||||
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.get },
|
||||
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.remove },
|
||||
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .policy = .read, .handler = blocklists.list },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .policy = .config_write, .handler = blocklists.create },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .policy = .runtime_action, .handler = blocklists.refresh },
|
||||
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .read, .handler = blocklists.get },
|
||||
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .handler = blocklists.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .handler = blocklists.remove },
|
||||
|
||||
// Rules.
|
||||
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .handler = rules.list },
|
||||
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .handler = rules.create },
|
||||
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.get },
|
||||
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.remove },
|
||||
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .policy = .read, .handler = rules.list },
|
||||
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .policy = .config_write, .handler = rules.create },
|
||||
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .policy = .read, .handler = rules.get },
|
||||
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .handler = rules.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .handler = rules.remove },
|
||||
|
||||
// Local records.
|
||||
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .handler = local.listRecords },
|
||||
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .handler = local.createRecord },
|
||||
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.getRecord },
|
||||
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.updateRecord },
|
||||
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.removeRecord },
|
||||
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .policy = .read, .handler = local.listRecords },
|
||||
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .policy = .config_write, .handler = local.createRecord },
|
||||
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .read, .handler = local.getRecord },
|
||||
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .handler = local.updateRecord },
|
||||
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .handler = local.removeRecord },
|
||||
|
||||
// Forward zones.
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .handler = local.listZones },
|
||||
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .handler = local.createZone },
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.getZone },
|
||||
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.updateZone },
|
||||
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.removeZone },
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .policy = .read, .handler = local.listZones },
|
||||
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .policy = .config_write, .handler = local.createZone },
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .read, .handler = local.getZone },
|
||||
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .handler = local.updateZone },
|
||||
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .handler = local.removeZone },
|
||||
|
||||
// Clients (no POST — rows come from DNS activity or import, ruling 9).
|
||||
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .handler = clients.list },
|
||||
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.get },
|
||||
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.remove },
|
||||
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.listPrefixes },
|
||||
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.putPrefixes },
|
||||
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .policy = .read, .handler = clients.list },
|
||||
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .policy = .read, .handler = clients.get },
|
||||
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .policy = .config_write, .handler = clients.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .policy = .runtime_action, .handler = clients.remove },
|
||||
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .policy = .read, .handler = clients.listPrefixes },
|
||||
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .policy = .config_write, .handler = clients.putPrefixes },
|
||||
|
||||
// Upstreams (restart-required resource).
|
||||
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.list },
|
||||
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.create },
|
||||
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.get },
|
||||
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.remove },
|
||||
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .policy = .read, .handler = upstreams.list },
|
||||
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .policy = .config_write, .handler = upstreams.create },
|
||||
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .read, .handler = upstreams.get },
|
||||
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .handler = upstreams.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .handler = upstreams.remove },
|
||||
|
||||
// Pause and settings.
|
||||
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .handler = pause.get },
|
||||
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .handler = pause.post },
|
||||
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .handler = settings.get },
|
||||
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .handler = settings.put },
|
||||
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .policy = .read, .handler = pause.get },
|
||||
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .handler = pause.post },
|
||||
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .policy = .read, .handler = settings.get },
|
||||
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .policy = .config_write, .handler = settings.put },
|
||||
|
||||
// Certificates (milestone-10 ruling 8).
|
||||
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .handler = certs.post },
|
||||
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .handler = certs.post },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -187,6 +198,71 @@ test "the limiter exemptions are the monitoring endpoints and the live stream" {
|
||||
try testing.expectEqual(exempt.len, found);
|
||||
}
|
||||
|
||||
test "the config writes are exactly the declarative mutations" {
|
||||
const writes = [_][]const u8{
|
||||
"POST /api/groups",
|
||||
"PUT /api/groups/{id}",
|
||||
"DELETE /api/groups/{id}",
|
||||
"PUT /api/groups/{id}/sources",
|
||||
"POST /api/blocklists",
|
||||
"PUT /api/blocklists/{id}",
|
||||
"DELETE /api/blocklists/{id}",
|
||||
"POST /api/rules",
|
||||
"PUT /api/rules/{id}",
|
||||
"DELETE /api/rules/{id}",
|
||||
"POST /api/local-records",
|
||||
"PUT /api/local-records/{id}",
|
||||
"DELETE /api/local-records/{id}",
|
||||
"POST /api/forward-zones",
|
||||
"PUT /api/forward-zones/{id}",
|
||||
"DELETE /api/forward-zones/{id}",
|
||||
"PUT /api/clients/{id}",
|
||||
"PUT /api/client-prefixes",
|
||||
"POST /api/upstreams",
|
||||
"PUT /api/upstreams/{id}",
|
||||
"DELETE /api/upstreams/{id}",
|
||||
"PUT /api/settings",
|
||||
};
|
||||
try expectClass(.config_write, &writes);
|
||||
}
|
||||
|
||||
test "the runtime actions are exactly ruling 7's list" {
|
||||
const actions = [_][]const u8{
|
||||
"POST /api/auth/login",
|
||||
"POST /api/auth/logout",
|
||||
"POST /api/blocklists/update",
|
||||
"DELETE /api/clients/{id}",
|
||||
"POST /api/pause",
|
||||
"POST /api/certs/reload",
|
||||
};
|
||||
try expectClass(.runtime_action, &actions);
|
||||
}
|
||||
|
||||
test "every read is a GET and every GET is a read" {
|
||||
for (table) |route| {
|
||||
try testing.expectEqual(route.method == .GET, route.policy == .read);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asserts that the routes classified `policy` are exactly `expected`, each
|
||||
/// written `METHOD /pattern`.
|
||||
fn expectClass(policy: router.Policy, expected: []const []const u8) !void {
|
||||
var buf: [64]u8 = undefined;
|
||||
var found: usize = 0;
|
||||
for (table) |route| {
|
||||
if (route.policy != policy) continue;
|
||||
found += 1;
|
||||
const label = try std.fmt.bufPrint(&buf, "{t} {s}", .{ route.method, route.pattern });
|
||||
var listed = false;
|
||||
for (expected) |name| listed = listed or std.mem.eql(u8, label, name);
|
||||
if (!listed) {
|
||||
std.debug.print("{s} is {t}, and the list does not say so\n", .{ label, policy });
|
||||
return error.TestUnexpectedResult;
|
||||
}
|
||||
}
|
||||
try testing.expectEqual(expected.len, found);
|
||||
}
|
||||
|
||||
test "item routes capture one id and collection routes capture none" {
|
||||
for (table) |route| {
|
||||
const captures = std.mem.count(u8, route.pattern, "{id}");
|
||||
|
||||
@@ -102,10 +102,31 @@ pub const ReloadFn = *const fn (state: *WebState, io: std.Io) anyerror!void;
|
||||
/// false` means several of them are never opened at all (ruling 6). A handler
|
||||
/// that finds the collaborator it needs missing answers 503, the same way it
|
||||
/// answers a missing snapshot.
|
||||
/// Which of the two sources governs this process's configuration (milestone-20
|
||||
/// ruling 1). Per-process state, never persisted: authority lives in the
|
||||
/// invocation, and the database carries no record of who wrote it.
|
||||
///
|
||||
/// The `managed_file` path is owned by `serve`'s arena, which outlives every
|
||||
/// `WebState`, so nothing here copies it.
|
||||
pub const Authority = union(enum) {
|
||||
database,
|
||||
managed_file: []const u8,
|
||||
};
|
||||
|
||||
pub const WebState = struct {
|
||||
gpa: Allocator,
|
||||
web: model.Web = .{},
|
||||
|
||||
/// Defaults to `.database`: a `WebState` nobody told about a managed file
|
||||
/// governs nothing declaratively, which is the safe reading — the mutation
|
||||
/// routes stay live rather than a half-wired server refusing every write.
|
||||
authority: Authority = .database,
|
||||
/// When this process loaded the managed file, in epoch seconds. Null in
|
||||
/// database mode, which never reconciles. It answers exactly "this process
|
||||
/// loaded the file at T" and nothing more: a file whose mtime is newer has
|
||||
/// not been loaded by the running process.
|
||||
reconciled_at: ?i64 = null,
|
||||
|
||||
handler: ?*dns_handler.Handler = null,
|
||||
pause: ?*pause_mod.Pause = null,
|
||||
tracker: ?*clients.Tracker = null,
|
||||
|
||||
@@ -90,11 +90,11 @@ fn bodyThenPathHandler(
|
||||
}
|
||||
|
||||
const test_routes = [_]router.RouteInfo{
|
||||
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = okHandler, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = okHandler },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = echoLengthHandler },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = bodyThenPathHandler },
|
||||
.{ .method = .GET, .pattern = "/api/lookup", .auth = .open, .handler = echoDomainHandler },
|
||||
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = okHandler, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = okHandler },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = echoLengthHandler },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = bodyThenPathHandler },
|
||||
.{ .method = .GET, .pattern = "/api/lookup", .auth = .open, .policy = .read, .handler = echoDomainHandler },
|
||||
};
|
||||
|
||||
fn denyAll(state: *server.WebState, io: std.Io, request: *const http_util.Request) bool {
|
||||
|
||||
@@ -264,6 +264,10 @@ const EnvOptions = struct {
|
||||
sse_max_per_ip: u16 = 3,
|
||||
trusted_proxies: []const u8 = "",
|
||||
fallback: ?router.HandlerFn = null,
|
||||
/// Milestone-20 ruling 7. `.database` is what every pre-existing test
|
||||
/// wants; the file-authority tests below name a path.
|
||||
authority: server.Authority = .database,
|
||||
reconciled_at: ?i64 = null,
|
||||
};
|
||||
|
||||
/// Heap-allocated because `state` and the listener hold pointers into it.
|
||||
@@ -372,6 +376,8 @@ const Env = struct {
|
||||
.sse_max_connections_per_ip = options.sse_max_per_ip,
|
||||
.trusted_proxies = options.trusted_proxies,
|
||||
},
|
||||
.authority = options.authority,
|
||||
.reconciled_at = options.reconciled_at,
|
||||
.live_hash = .init(options.password_hash),
|
||||
.pause = &self.pauser,
|
||||
.manager = &self.mgr,
|
||||
@@ -573,6 +579,7 @@ const SettingsView = struct {
|
||||
blocklist_update: struct { enabled: bool, interval_hours: u16 },
|
||||
},
|
||||
restart_required: []const []const u8,
|
||||
authority: struct { mode: []const u8, path: ?[]const u8, reconciled_at: ?i64 },
|
||||
};
|
||||
|
||||
const Contract = struct {
|
||||
@@ -580,6 +587,9 @@ const Contract = struct {
|
||||
/// Must equal a `routes.zig` pattern; the coverage test enforces it.
|
||||
pattern: []const u8,
|
||||
auth: router.Auth,
|
||||
/// Milestone-20 ruling 7's class, restated here so the coverage test can
|
||||
/// hold the served table to it. No default, like the route table.
|
||||
policy: router.Policy,
|
||||
rate_limit: router.RateLimit = .counted,
|
||||
/// The concrete request target the walk sends.
|
||||
target: []const u8,
|
||||
@@ -597,96 +607,96 @@ const Contract = struct {
|
||||
/// create that made the row, and deletes come last for their resource.
|
||||
const contract = [_]Contract{
|
||||
// Monitoring and contract.
|
||||
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .rate_limit = .exempt, .target = "/metrics", .status = 200, .kind = .raw, .needle = "nxdns_up 1" },
|
||||
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .rate_limit = .exempt, .target = "/api/health", .status = 200, .check = jsonShape(handlers_health.Body) },
|
||||
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .target = "/api/version", .status = 200, .check = jsonShape(handlers_version.Body) },
|
||||
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .target = "/api/openapi.yaml", .status = 200, .kind = .raw, .needle = "openapi: 3.0.3" },
|
||||
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .policy = .read, .rate_limit = .exempt, .target = "/metrics", .status = 200, .kind = .raw, .needle = "nxdns_up 1" },
|
||||
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .rate_limit = .exempt, .target = "/api/health", .status = 200, .check = jsonShape(handlers_health.Body) },
|
||||
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .policy = .read, .target = "/api/version", .status = 200, .check = jsonShape(handlers_version.Body) },
|
||||
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .policy = .read, .target = "/api/openapi.yaml", .status = 200, .kind = .raw, .needle = "openapi: 3.0.3" },
|
||||
|
||||
// Authentication (auth is disabled in the walk's environment; the on/off
|
||||
// matrix has its own test).
|
||||
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200, .check = jsonShape(LoginView) },
|
||||
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .target = "/api/auth/logout", .status = 200, .check = jsonShape(LogoutView) },
|
||||
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .policy = .runtime_action, .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200, .check = jsonShape(LoginView) },
|
||||
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .policy = .runtime_action, .target = "/api/auth/logout", .status = 200, .check = jsonShape(LogoutView) },
|
||||
|
||||
// Refresh-all before any source row exists: nothing to fetch, 202 anyway.
|
||||
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .target = "/api/blocklists/update", .status = 202, .check = jsonShape(StatusList) },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .policy = .runtime_action, .target = "/api/blocklists/update", .status = 202, .check = jsonShape(StatusList) },
|
||||
|
||||
// Query log, stats, live stream, upstream health.
|
||||
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .target = "/api/queries?limit=10", .status = 200, .check = jsonShape(handlers_queries.Page) },
|
||||
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse },
|
||||
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) },
|
||||
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) },
|
||||
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .target = "/api/upstream/health", .status = 200, .check = jsonShape(handlers_upstream_health.Body) },
|
||||
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .target = "/api/queries?limit=10", .status = 200, .check = jsonShape(handlers_queries.Page) },
|
||||
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse },
|
||||
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) },
|
||||
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) },
|
||||
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .target = "/api/upstream/health", .status = 200, .check = jsonShape(handlers_upstream_health.Body) },
|
||||
|
||||
// Groups. The migrated schema seeds `default` as id 1; the POST creates
|
||||
// id 2, which the delete at the end of the walk removes.
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .target = "/api/groups", .status = 200, .check = jsonShape(GroupsList) },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201, .check = jsonShape(GroupEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .status = 200, .check = jsonShape(groups_repo.GroupRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200, .check = jsonShape(GroupEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .target = "/api/groups/1/sources", .status = 200, .check = jsonShape(SourceIds) },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .target = "/api/groups/1/sources", .body = "{\"source_ids\":[]}", .status = 200, .check = jsonShape(SourceIds) },
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .target = "/api/groups", .status = 200, .check = jsonShape(GroupsList) },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201, .check = jsonShape(GroupEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .target = "/api/groups/2", .status = 200, .check = jsonShape(groups_repo.GroupRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200, .check = jsonShape(GroupEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .read, .target = "/api/groups/1/sources", .status = 200, .check = jsonShape(SourceIds) },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .target = "/api/groups/1/sources", .body = "{\"source_ids\":[]}", .status = 200, .check = jsonShape(SourceIds) },
|
||||
|
||||
// Blocklist sources. The POST runs after the refresh above, so the created
|
||||
// row's url is never fetched.
|
||||
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .target = "/api/blocklists", .status = 200, .check = jsonShape(SourcesList) },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .target = "/api/blocklists", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads\"}", .status = 201, .check = jsonShape(SourceEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .status = 200, .check = jsonShape(sources_repo.SourceRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200, .check = jsonShape(SourceEcho) },
|
||||
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .status = 204, .kind = .none },
|
||||
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .policy = .read, .target = "/api/blocklists", .status = 200, .check = jsonShape(SourcesList) },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .policy = .config_write, .target = "/api/blocklists", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads\"}", .status = 201, .check = jsonShape(SourceEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .read, .target = "/api/blocklists/1", .status = 200, .check = jsonShape(sources_repo.SourceRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200, .check = jsonShape(SourceEcho) },
|
||||
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .target = "/api/blocklists/1", .status = 204, .kind = .none },
|
||||
|
||||
// Rules. The lookup below wants the blocking rule still in place, so the
|
||||
// rule's delete follows it.
|
||||
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .target = "/api/rules", .status = 200, .check = jsonShape(RulesList) },
|
||||
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201, .check = jsonShape(RuleEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .status = 200, .check = jsonShape(RuleShape) },
|
||||
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 200, .check = jsonShape(RuleEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .target = "/api/lookup?domain=ads.example", .status = 200, .check = jsonShape(handlers_lookup.Body) },
|
||||
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .status = 204, .kind = .none },
|
||||
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .policy = .read, .target = "/api/rules", .status = 200, .check = jsonShape(RulesList) },
|
||||
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .policy = .config_write, .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201, .check = jsonShape(RuleEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .policy = .read, .target = "/api/rules/1", .status = 200, .check = jsonShape(RuleShape) },
|
||||
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 200, .check = jsonShape(RuleEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .target = "/api/lookup?domain=ads.example", .status = 200, .check = jsonShape(handlers_lookup.Body) },
|
||||
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .target = "/api/rules/1", .status = 204, .kind = .none },
|
||||
|
||||
// Local records.
|
||||
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .target = "/api/local-records", .status = 200, .check = jsonShape(RecordsList) },
|
||||
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201, .check = jsonShape(RecordShape) },
|
||||
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .status = 200, .check = jsonShape(RecordShape) },
|
||||
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200, .check = jsonShape(RecordShape) },
|
||||
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .status = 204, .kind = .none },
|
||||
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .policy = .read, .target = "/api/local-records", .status = 200, .check = jsonShape(RecordsList) },
|
||||
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .policy = .config_write, .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201, .check = jsonShape(RecordShape) },
|
||||
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .read, .target = "/api/local-records/1", .status = 200, .check = jsonShape(RecordShape) },
|
||||
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200, .check = jsonShape(RecordShape) },
|
||||
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .target = "/api/local-records/1", .status = 204, .kind = .none },
|
||||
|
||||
// Forward zones.
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .target = "/api/forward-zones", .status = 200, .check = jsonShape(ZonesList) },
|
||||
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201, .check = jsonShape(local_repo.ForwardZoneRow) },
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
|
||||
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .status = 204, .kind = .none },
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .policy = .read, .target = "/api/forward-zones", .status = 200, .check = jsonShape(ZonesList) },
|
||||
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .policy = .config_write, .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201, .check = jsonShape(local_repo.ForwardZoneRow) },
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .read, .target = "/api/forward-zones/1", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
|
||||
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .target = "/api/forward-zones/1", .status = 204, .kind = .none },
|
||||
|
||||
// Clients (row id 1 is seeded — clients have no POST, ruling 9).
|
||||
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .target = "/api/clients", .status = 200, .check = jsonShape(ClientsList) },
|
||||
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
|
||||
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .status = 204, .kind = .none },
|
||||
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .target = "/api/client-prefixes", .status = 200, .check = jsonShape(PrefixesList) },
|
||||
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200, .check = jsonShape(PrefixesList) },
|
||||
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .policy = .read, .target = "/api/clients", .status = 200, .check = jsonShape(ClientsList) },
|
||||
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .policy = .read, .target = "/api/clients/1", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .policy = .config_write, .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
|
||||
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .policy = .runtime_action, .target = "/api/clients/1", .status = 204, .kind = .none },
|
||||
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .policy = .read, .target = "/api/client-prefixes", .status = 200, .check = jsonShape(PrefixesList) },
|
||||
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .policy = .config_write, .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200, .check = jsonShape(PrefixesList) },
|
||||
|
||||
// Upstreams. Row id 1 is seeded; the POST creates id 2, whose delete
|
||||
// cannot collide with the last-enabled-upstream guard.
|
||||
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .target = "/api/upstreams", .status = 200, .check = jsonShape(UpstreamsList) },
|
||||
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201, .check = jsonShape(UpstreamEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/1", .status = 200, .check = jsonShape(upstreams_repo.UpstreamRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200, .check = jsonShape(UpstreamEcho) },
|
||||
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/2", .status = 204, .kind = .none },
|
||||
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .policy = .read, .target = "/api/upstreams", .status = 200, .check = jsonShape(UpstreamsList) },
|
||||
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .policy = .config_write, .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201, .check = jsonShape(UpstreamEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .read, .target = "/api/upstreams/1", .status = 200, .check = jsonShape(upstreams_repo.UpstreamRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200, .check = jsonShape(UpstreamEcho) },
|
||||
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .target = "/api/upstreams/2", .status = 204, .kind = .none },
|
||||
|
||||
// Pause and settings. The pause POST leaves filtering running; the
|
||||
// settings PUT is a real change, echoed by the same response shape.
|
||||
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .target = "/api/pause", .status = 200, .check = jsonShape(handlers_pause.View) },
|
||||
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .target = "/api/pause", .body = "{\"paused\":false}", .status = 200, .check = jsonShape(handlers_pause.View) },
|
||||
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .target = "/api/settings", .status = 200, .check = jsonShape(SettingsView) },
|
||||
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200, .check = jsonShape(SettingsView) },
|
||||
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .policy = .read, .target = "/api/pause", .status = 200, .check = jsonShape(handlers_pause.View) },
|
||||
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .target = "/api/pause", .body = "{\"paused\":false}", .status = 200, .check = jsonShape(handlers_pause.View) },
|
||||
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .policy = .read, .target = "/api/settings", .status = 200, .check = jsonShape(SettingsView) },
|
||||
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .policy = .config_write, .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200, .check = jsonShape(SettingsView) },
|
||||
|
||||
// Certificates. The walk's environment wires no cert store, so both
|
||||
// endpoints report disabled — and the reload still answers 200 (m10
|
||||
// ruling 8: the outcome is the payload).
|
||||
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .target = "/api/certs/reload", .status = 200, .check = jsonShape(handlers_certs.View) },
|
||||
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .target = "/api/certs/reload", .status = 200, .check = jsonShape(handlers_certs.View) },
|
||||
|
||||
// The walk's last delete returns the groups table to its seeded shape.
|
||||
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .status = 204, .kind = .none },
|
||||
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .target = "/api/groups/2", .status = 204, .kind = .none },
|
||||
};
|
||||
|
||||
// Drift guard: the contract table covers the served route table exactly —
|
||||
@@ -707,6 +717,7 @@ test "the contract table covers every served route with the served policy" {
|
||||
covered[index] = true;
|
||||
try testing.expectEqual(route.auth, entry.auth);
|
||||
try testing.expectEqual(route.rate_limit, entry.rate_limit);
|
||||
try testing.expectEqual(route.policy, entry.policy);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
@@ -933,6 +944,252 @@ test "W10 auth off: an empty hash leaves every route open" {
|
||||
try bounded(env.io(), default_budget, authOff, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// file authority (milestone-20 ruling 7)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const managed_path = "/etc/nxdns/config.zon";
|
||||
const managed_body = "{\"error\":\"configuration is managed by " ++ managed_path ++
|
||||
"; edit the file and restart\"}";
|
||||
|
||||
/// Long enough that the envelope could not be built in the 512-byte stack
|
||||
/// buffer `respondError` used before this milestone. Nested bind mounts really
|
||||
/// do produce paths like this, and the old code answered them in `text/plain`.
|
||||
const long_managed_path = "/mnt/" ++ ("deeply-nested-bind-mount/" ** 24) ++ "config.zon";
|
||||
|
||||
fn fileModeClasses(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [8192]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
// A read is untouched.
|
||||
try conn.request("GET", "/api/groups", null, null);
|
||||
var response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
// Every class of configuration write answers the one envelope.
|
||||
const writes = [_]struct { method: []const u8, target: []const u8, body: ?[]const u8 }{
|
||||
.{ .method = "POST", .target = "/api/groups", .body = "{\"name\":\"kids\"}" },
|
||||
.{ .method = "PUT", .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}" },
|
||||
.{ .method = "PUT", .target = "/api/clients/1", .body = "{\"name\":\"x\",\"group_id\":1}" },
|
||||
.{ .method = "DELETE", .target = "/api/upstreams/1", .body = null },
|
||||
};
|
||||
for (writes) |write| {
|
||||
try conn.request(write.method, write.target, null, write.body);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 403), response.status);
|
||||
try testing.expectEqualStrings(managed_body, response.body);
|
||||
try testing.expectEqualStrings("application/json", response.header("content-type").?);
|
||||
}
|
||||
|
||||
// Rejected before the handler, not after it: the group was never created.
|
||||
try conn.request("GET", "/api/groups", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "kids"));
|
||||
|
||||
// Runtime actions stay live.
|
||||
try conn.request("POST", "/api/pause", null, "{\"paused\":false}");
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
try conn.request("POST", "/api/blocklists/update", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 202), response.status);
|
||||
|
||||
try conn.request("POST", "/api/certs/reload", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
}
|
||||
|
||||
test "W10 milestone 20: file authority rejects configuration writes and spares the rest" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = managed_path } });
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, fileModeClasses, .{ env.io(), env });
|
||||
}
|
||||
|
||||
fn fileModeClientDelete(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [4096]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
// The declared row contradicts the file, so it stays.
|
||||
try conn.request("DELETE", "/api/clients/2", null, null);
|
||||
var response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 403), response.status);
|
||||
try testing.expectEqualStrings(managed_body, response.body);
|
||||
|
||||
// The observed row is runtime state the file never declared; without this
|
||||
// a departed device would be immortal, since the file can only promote an
|
||||
// address, never forget one.
|
||||
try conn.request("DELETE", "/api/clients/1", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 204), response.status);
|
||||
|
||||
// An id no client holds is still a 404, not a policy verdict.
|
||||
try conn.request("DELETE", "/api/clients/999", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 404), response.status);
|
||||
}
|
||||
|
||||
test "W10 milestone 20: file authority deletes an observed client and refuses a declared one" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = managed_path } });
|
||||
defer env.destroy();
|
||||
|
||||
// Row 1 is seeded observed (`hand_edited = 0`); row 2 is what the file
|
||||
// declares.
|
||||
try env.config_db.exec(
|
||||
\\INSERT INTO clients (id, ip, name, group_id, hand_edited, first_seen, last_seen)
|
||||
\\VALUES (2, '192.168.1.51', 'nas', 1, 1, 1700000000, 1700000000)
|
||||
);
|
||||
|
||||
try bounded(env.io(), default_budget, fileModeClientDelete, .{ env.io(), env });
|
||||
}
|
||||
|
||||
fn longPathEnvelope(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [8192]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
|
||||
const response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 403), response.status);
|
||||
try testing.expect(response.body.len > 512);
|
||||
try testing.expectEqualStrings("application/json", response.header("content-type").?);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, long_managed_path));
|
||||
|
||||
// Still the documented envelope, not a truncation and not plain text.
|
||||
const parsed = try std.json.parseFromSlice(
|
||||
struct { @"error": []const u8 },
|
||||
env.gpa,
|
||||
response.body,
|
||||
.{},
|
||||
);
|
||||
defer parsed.deinit();
|
||||
}
|
||||
|
||||
test "W10 milestone 20: an error longer than the old 512-byte buffer stays application/json" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = long_managed_path } });
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, longPathEnvelope, .{ env.io(), env });
|
||||
}
|
||||
|
||||
fn fileModeUnauthenticated(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [4096]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
// Policy runs after authentication: a caller with no session learns that
|
||||
// it needs one, never that the route exists and is managed by a file whose
|
||||
// path the envelope would otherwise disclose.
|
||||
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
|
||||
const response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 401), response.status);
|
||||
try testing.expectEqualStrings("{\"error\":\"authentication required\"}", response.body);
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, managed_path));
|
||||
}
|
||||
|
||||
test "W10 milestone 20: an unauthenticated configuration write is 401, never 403" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var hash_buf: [256]u8 = undefined;
|
||||
const hash = try hashTestPassword(gpa, &hash_buf);
|
||||
|
||||
var env = try Env.create(gpa, .{
|
||||
.password_hash = hash,
|
||||
.authority = .{ .managed_file = managed_path },
|
||||
});
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, fileModeUnauthenticated, .{ env.io(), env });
|
||||
}
|
||||
|
||||
fn authorityEnvelope(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [16384]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
try conn.request("GET", "/api/settings", null, null);
|
||||
var response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{});
|
||||
defer parsed.deinit();
|
||||
try testing.expectEqualStrings("managed_file", parsed.value.authority.mode);
|
||||
try testing.expectEqualStrings(managed_path, parsed.value.authority.path.?);
|
||||
try testing.expectEqual(@as(?i64, 1_700_000_042), parsed.value.authority.reconciled_at);
|
||||
|
||||
// The path is a filesystem path and must not reach the open routes.
|
||||
for ([_][]const u8{ "/api/version", "/api/health" }) |target| {
|
||||
try conn.request("GET", target, null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, managed_path));
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "authority"));
|
||||
}
|
||||
}
|
||||
|
||||
test "W10 milestone 20: the settings envelope reports the authority and the open routes do not" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{
|
||||
.authority = .{ .managed_file = managed_path },
|
||||
.reconciled_at = 1_700_000_042,
|
||||
});
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, authorityEnvelope, .{ env.io(), env });
|
||||
}
|
||||
|
||||
fn databaseAuthorityEnvelope(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [16384]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
try conn.request("GET", "/api/settings", null, null);
|
||||
const response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{});
|
||||
defer parsed.deinit();
|
||||
try testing.expectEqualStrings("database", parsed.value.authority.mode);
|
||||
try testing.expectEqual(@as(?[]const u8, null), parsed.value.authority.path);
|
||||
try testing.expectEqual(@as(?i64, null), parsed.value.authority.reconciled_at);
|
||||
|
||||
// And nothing is rejected.
|
||||
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
|
||||
const created = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 201), created.status);
|
||||
}
|
||||
|
||||
test "W10 milestone 20: database authority reports null and writes normally" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{});
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, databaseAuthorityEnvelope, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// oversized cookie headers (ruling 7 of milestone 16)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+2541
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
dist/
|
||||
dist-placeholder/
|
||||
package-lock.json
|
||||
dist-sourcemap/
|
||||
|
||||
+2
-1
@@ -13,7 +13,8 @@
|
||||
"lint": "oxlint src vite.config.ts",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"assert-bundled": "node scripts/assert-bundled-packages.mjs"
|
||||
},
|
||||
"prettier": {
|
||||
"useTabs": true,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env node
|
||||
// The set of npm packages whose bytes reach web/dist must be exactly the set
|
||||
// recorded in licenses/dependency-identity.txt (milestone-14 ruling 3).
|
||||
//
|
||||
// The shipped build carries no sourcemaps, so this makes a second build with
|
||||
// them into its own directory: the `sources` list of each chunk names the
|
||||
// modules that went into it, and the artifact `npm run build` produced stays
|
||||
// untouched. Runs from web/ as `npm run assert-bundled`, on a laptop exactly as
|
||||
// on the runner.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { bundledPackages, comparePackages, formatDiff, recordedPackages } from "./bundledPackages.mjs";
|
||||
|
||||
const webRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const outDir = "dist-sourcemap";
|
||||
const identityFile = join(webRoot, "..", "licenses", "dependency-identity.txt");
|
||||
|
||||
function fail(message) {
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function mapFiles(relativeDir) {
|
||||
const absolute = join(webRoot, relativeDir);
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(absolute, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
fail(`assert-bundled: cannot read ${relativeDir}: ${err.message}`);
|
||||
}
|
||||
const found = [];
|
||||
for (const entry of entries) {
|
||||
const child = `${relativeDir}/${entry.name}`;
|
||||
if (entry.isDirectory()) {
|
||||
found.push(...mapFiles(child));
|
||||
} else if (entry.isFile() && entry.name.endsWith(".map")) {
|
||||
found.push(child);
|
||||
}
|
||||
}
|
||||
return found.sort();
|
||||
}
|
||||
|
||||
// The binary npm ci installed, never `npx`: npx silently downloads a package it
|
||||
// cannot find locally, so a wrong working directory would turn a licence check
|
||||
// into an unpinned fetch from the network.
|
||||
try {
|
||||
execFileSync(
|
||||
join(webRoot, "node_modules", ".bin", "vite"),
|
||||
["build", "--sourcemap", "--outDir", outDir, "--emptyOutDir"],
|
||||
{
|
||||
cwd: webRoot,
|
||||
stdio: ["ignore", "ignore", "inherit"],
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
fail(`assert-bundled: the sourcemap build failed: ${err.message}`);
|
||||
}
|
||||
|
||||
const maps = mapFiles(outDir);
|
||||
if (maps.length === 0) fail("assert-bundled: the sourcemap build produced no .map files; this check cannot run blind");
|
||||
|
||||
const sourceLists = maps.map((path) => {
|
||||
const raw = readFileSync(join(webRoot, path), "utf8");
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
fail(`assert-bundled: ${path} is not JSON: ${err.message}`);
|
||||
}
|
||||
return Array.isArray(parsed.sources) ? parsed.sources : [];
|
||||
});
|
||||
|
||||
const bundled = bundledPackages(sourceLists);
|
||||
|
||||
let identity;
|
||||
try {
|
||||
identity = readFileSync(identityFile, "utf8");
|
||||
} catch (err) {
|
||||
fail(`assert-bundled: cannot read licenses/dependency-identity.txt: ${err.message}`);
|
||||
}
|
||||
|
||||
const recorded = recordedPackages(identity);
|
||||
if (recorded === null) {
|
||||
fail("assert-bundled: licenses/dependency-identity.txt has no '[npm packages bundled into web/dist]' section");
|
||||
}
|
||||
if (recorded.length === 0) {
|
||||
fail("assert-bundled: the '[npm packages bundled into web/dist]' section is empty");
|
||||
}
|
||||
|
||||
const { added, removed } = comparePackages(recorded, bundled);
|
||||
if (added.length !== 0 || removed.length !== 0) {
|
||||
process.stderr.write(`${formatDiff(recorded, bundled)}\n\n`);
|
||||
fail(
|
||||
[
|
||||
"the set of npm packages in web/dist has changed (-recorded +current).",
|
||||
"Work out what the change means for licenses/inventory.zon first, then record",
|
||||
"the new list in that section of licenses/dependency-identity.txt.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
process.stdout.write(`web/dist bundles exactly the ${bundled.length} recorded packages:\n`);
|
||||
for (const name of bundled) process.stdout.write(`${name}\n`);
|
||||
@@ -0,0 +1,77 @@
|
||||
// The decisions behind `npm run assert-bundled`, kept separate from the script
|
||||
// that does the I/O so they can be unit-tested (milestone-14 deviation 24).
|
||||
//
|
||||
// The licence inventory has to cover every package whose bytes ship, and the
|
||||
// lockfile does not answer that question: it lists what could be reached, not
|
||||
// what rollup kept. Several packages of the non-dev closure are recorded as
|
||||
// tree-shaken away, and if application code starts importing one of them, no
|
||||
// lockfile, no version and no dependency set changes — only the bundle does. So
|
||||
// the bundle is what this reads.
|
||||
|
||||
const sectionHeading = "[npm packages bundled into web/dist]";
|
||||
|
||||
// A sourcemap `sources` entry for a dependency ends in
|
||||
// `node_modules/<name>/<file>` or `node_modules/@<scope>/<name>/<file>`. Only
|
||||
// the last `node_modules/` matters: a nested dependency's path carries two.
|
||||
export function packageFromSource(source) {
|
||||
const marker = "node_modules/";
|
||||
const at = source.lastIndexOf(marker);
|
||||
if (at === -1) return null;
|
||||
const rest = source.slice(at + marker.length);
|
||||
const parts = rest.split("/");
|
||||
if (parts.length === 0 || parts[0] === "") return null;
|
||||
if (parts[0].startsWith("@")) {
|
||||
if (parts.length < 2 || parts[1] === "") return null;
|
||||
return `${parts[0]}/${parts[1]}`;
|
||||
}
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
/// The sorted, deduplicated package set of a list of sourcemap `sources` arrays.
|
||||
export function bundledPackages(sourceLists) {
|
||||
const found = new Set();
|
||||
for (const sources of sourceLists) {
|
||||
for (const source of sources) {
|
||||
const name = packageFromSource(source);
|
||||
if (name !== null) found.add(name);
|
||||
}
|
||||
}
|
||||
return [...found].sort();
|
||||
}
|
||||
|
||||
/// The recorded section of `licenses/dependency-identity.txt`: every non-blank
|
||||
/// line after the heading, up to the next `[section]`.
|
||||
export function recordedPackages(text) {
|
||||
const recorded = new Set();
|
||||
let grabbing = false;
|
||||
for (const raw of text.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!grabbing) {
|
||||
if (line === sectionHeading) grabbing = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("[")) break;
|
||||
if (line !== "") recorded.add(line);
|
||||
}
|
||||
return grabbing ? [...recorded].sort() : null;
|
||||
}
|
||||
|
||||
/// What changed, in the two directions that mean different things: a package
|
||||
/// that started shipping needs a licence decision, and one that stopped needs
|
||||
/// the record corrected.
|
||||
export function comparePackages(recorded, bundled) {
|
||||
const inBundle = new Set(bundled);
|
||||
const inRecord = new Set(recorded);
|
||||
return {
|
||||
added: bundled.filter((name) => !inRecord.has(name)),
|
||||
removed: recorded.filter((name) => !inBundle.has(name)),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDiff(recorded, bundled) {
|
||||
const { added, removed } = comparePackages(recorded, bundled);
|
||||
const lines = [];
|
||||
for (const name of removed) lines.push(`-${name}`);
|
||||
for (const name of added) lines.push(`+${name}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
bundledPackages,
|
||||
comparePackages,
|
||||
formatDiff,
|
||||
packageFromSource,
|
||||
recordedPackages,
|
||||
} from "./bundledPackages.mjs";
|
||||
|
||||
describe("packageFromSource", () => {
|
||||
it("reads a plain package name", () => {
|
||||
expect(packageFromSource("../../node_modules/react-dom/client.js")).toBe("react-dom");
|
||||
});
|
||||
|
||||
it("keeps the scope of a scoped package", () => {
|
||||
expect(packageFromSource("../../node_modules/@tanstack/react-query/build/index.js")).toBe(
|
||||
"@tanstack/react-query",
|
||||
);
|
||||
});
|
||||
|
||||
it("takes the last node_modules, so a nested dependency is named correctly", () => {
|
||||
expect(packageFromSource("node_modules/vite/node_modules/@scope/inner/x.js")).toBe("@scope/inner");
|
||||
});
|
||||
|
||||
it("ignores application sources", () => {
|
||||
expect(packageFromSource("src/lib/api.ts")).toBeNull();
|
||||
expect(packageFromSource("../src/main.tsx")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundledPackages", () => {
|
||||
it("sorts and deduplicates across every map", () => {
|
||||
const packages = bundledPackages([
|
||||
["node_modules/react/index.js", "src/main.tsx", "node_modules/react/jsx-runtime.js"],
|
||||
["node_modules/@tanstack/react-router/x.js", "node_modules/react/index.js"],
|
||||
]);
|
||||
expect(packages).toEqual(["@tanstack/react-router", "react"]);
|
||||
});
|
||||
|
||||
it("returns an empty set when nothing came from node_modules", () => {
|
||||
expect(bundledPackages([["src/main.tsx"]])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordedPackages", () => {
|
||||
const identity = [
|
||||
"[some earlier section]",
|
||||
"ignored",
|
||||
"",
|
||||
"[npm packages bundled into web/dist]",
|
||||
"react",
|
||||
"@tanstack/react-query",
|
||||
"",
|
||||
"react-dom",
|
||||
"",
|
||||
"[a later section]",
|
||||
"not-a-package",
|
||||
].join("\n");
|
||||
|
||||
it("reads only its own section, sorted and deduplicated", () => {
|
||||
expect(recordedPackages(identity)).toEqual(["@tanstack/react-query", "react", "react-dom"]);
|
||||
});
|
||||
|
||||
it("distinguishes a missing section from an empty one", () => {
|
||||
expect(recordedPackages("[other]\nx\n")).toBeNull();
|
||||
expect(recordedPackages("[npm packages bundled into web/dist]\n\n[next]\n")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("comparePackages", () => {
|
||||
it("reports both directions", () => {
|
||||
const { added, removed } = comparePackages(["a", "b"], ["b", "c"]);
|
||||
expect(added).toEqual(["c"]);
|
||||
expect(removed).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("reports nothing when the sets match", () => {
|
||||
expect(comparePackages(["a", "b"], ["a", "b"])).toEqual({ added: [], removed: [] });
|
||||
expect(formatDiff(["a"], ["a"])).toBe("");
|
||||
});
|
||||
|
||||
it("formats a diff the way the failure prints it", () => {
|
||||
expect(formatDiff(["a", "b"], ["b", "c"])).toBe("-a\n+c");
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,9 @@ test("swallowMutationError drops an ApiError and rethrows anything else", () =>
|
||||
|
||||
test("a rejected submit leaves the typed values in place; a resolved one clears them", async () => {
|
||||
const rejecting = vi.fn(() => Promise.reject(new ApiError(400, "bad url")));
|
||||
const { rerender } = render(<BlocklistForm busy={false} error={null} onSubmit={rejecting} onCancel={undefined} />);
|
||||
const { rerender } = render(
|
||||
<BlocklistForm busy={false} readOnly={false} error={null} onSubmit={rejecting} onCancel={undefined} />,
|
||||
);
|
||||
const url = screen.getByLabelText("URL") as HTMLInputElement;
|
||||
const name = screen.getByLabelText("Name") as HTMLInputElement;
|
||||
fireEvent.change(url, { target: { value: "https://example.com/list.txt" } });
|
||||
@@ -22,7 +24,7 @@ test("a rejected submit leaves the typed values in place; a resolved one clears
|
||||
expect(name.value).toBe("Example");
|
||||
|
||||
const resolving = vi.fn(() => Promise.resolve());
|
||||
rerender(<BlocklistForm busy={false} error={null} onSubmit={resolving} onCancel={undefined} />);
|
||||
rerender(<BlocklistForm busy={false} readOnly={false} error={null} onSubmit={resolving} onCancel={undefined} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add source" }));
|
||||
await waitFor(() => expect(url.value).toBe(""));
|
||||
expect(name.value).toBe("");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ApiError } from "@/lib/api";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import type { Blocklist, BlocklistInput } from "@/lib/types";
|
||||
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
|
||||
import { READ_ONLY_HINT } from "@/features/settings/authority";
|
||||
|
||||
/**
|
||||
* Drops the rejection the page already renders inline below the form. Anything
|
||||
@@ -17,12 +18,14 @@ export function swallowMutationError(error: unknown): void {
|
||||
interface BlocklistFormProps {
|
||||
initial?: Blocklist;
|
||||
busy: boolean;
|
||||
/** File authority: the server answers 403, so the submit stays down. */
|
||||
readOnly: boolean;
|
||||
error: Error | null;
|
||||
onSubmit: (input: BlocklistInput) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel }: BlocklistFormProps) {
|
||||
export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit, onCancel }: BlocklistFormProps) {
|
||||
const [url, setUrl] = useState(initial?.url ?? "");
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
|
||||
@@ -81,7 +84,12 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
|
||||
Enabled
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="submit" disabled={busy} className={primaryButtonClass}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
{initial === undefined ? "Add source" : "Save changes"}
|
||||
</button>
|
||||
{onCancel !== undefined && (
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
tdClass,
|
||||
thClass,
|
||||
} from "@/ui/classes";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
export default function BlocklistsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -36,6 +37,9 @@ export default function BlocklistsPage() {
|
||||
|
||||
const sources = useRefreshStatus();
|
||||
const namesById = new Map(blocklists.map((b) => [b.id, b.name]));
|
||||
// The refresh below re-fetches the sources the config already declares, so
|
||||
// it stays live in file mode; every other control here writes config.
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
async function submitForm(input: BlocklistInput) {
|
||||
if (editing === null) {
|
||||
@@ -122,7 +126,8 @@ export default function BlocklistsPage() {
|
||||
type="checkbox"
|
||||
aria-label={`${b.name} enabled`}
|
||||
checked={b.enabled}
|
||||
disabled={toggle.isPending}
|
||||
disabled={toggle.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
onChange={() => toggleEnabled(b)}
|
||||
className={focusRing}
|
||||
/>
|
||||
@@ -138,14 +143,17 @@ export default function BlocklistsPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(b)}
|
||||
className={linkButtonClass}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={`${linkButtonClass} disabled:opacity-50`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteBlocklist(b)}
|
||||
disabled={remove.isPending}
|
||||
disabled={remove.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={dangerLinkButtonClass}
|
||||
>
|
||||
Delete
|
||||
@@ -164,6 +172,7 @@ export default function BlocklistsPage() {
|
||||
key={editing?.id ?? "add"}
|
||||
initial={editing ?? undefined}
|
||||
busy={editing === null ? create.isPending : save.isPending}
|
||||
readOnly={readOnly}
|
||||
error={formError}
|
||||
onSubmit={submitForm}
|
||||
onCancel={editing === null ? undefined : () => setEditing(null)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { clientUpdateMutation } from "@/lib/queries";
|
||||
import type { Client, Group } from "@/lib/types";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { buttonClass, primaryButtonClass, smallInputClass } from "@/ui/classes";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
interface Props {
|
||||
client: Client;
|
||||
@@ -18,6 +19,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
||||
const mutation = useMutation(clientUpdateMutation(queryClient));
|
||||
const [name, setName] = useState(client.name);
|
||||
const [groupId, setGroupId] = useState(client.group_id);
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
@@ -67,7 +69,12 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
||||
<button type="button" onClick={onClose} className={buttonClass}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={mutation.isPending} className={primaryButtonClass}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,17 @@ import ClientEditDialog from "./ClientEditDialog";
|
||||
import PrefixesEditor from "./PrefixesEditor";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { smallButtonClass, tableWrapClass } from "@/ui/classes";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
const cellClass = "px-3 py-2";
|
||||
|
||||
/**
|
||||
* Deleting an observed row discards runtime state the file never declared, so
|
||||
* it stays live under file authority; deleting a hand-edited row contradicts
|
||||
* the file and is the one client DELETE the server answers 403 (ruling 7).
|
||||
*/
|
||||
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart.";
|
||||
|
||||
export default function ClientsPage() {
|
||||
const { data: clients } = useSuspenseQuery(clientsQuery());
|
||||
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
|
||||
@@ -18,6 +26,7 @@ export default function ClientsPage() {
|
||||
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
|
||||
const [editing, setEditing] = useState<Client | null>(null);
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
return (
|
||||
<section>
|
||||
@@ -86,14 +95,22 @@ export default function ClientsPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(client)}
|
||||
className={smallButtonClass}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={`${smallButtonClass} disabled:opacity-50`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingId(client.id)}
|
||||
className={`${smallButtonClass} text-red-700 dark:text-red-400`}
|
||||
disabled={readOnly && client.hand_edited}
|
||||
title={
|
||||
readOnly && client.hand_edited
|
||||
? DECLARED_CLIENT_NOTE
|
||||
: undefined
|
||||
}
|
||||
className={`${smallButtonClass} text-red-700 disabled:opacity-50 dark:text-red-400`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { defaultGroupId } from "@/lib/defaultGroup";
|
||||
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
interface Props {
|
||||
prefixes: ClientPrefix[];
|
||||
@@ -19,6 +20,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||
const [validation, setValidation] = useState<string | null>(null);
|
||||
const dirty = isDirty(state);
|
||||
const fallbackGroupId = defaultGroupId(groups);
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
const save = () => {
|
||||
const problem = firstProblem(state.rows);
|
||||
@@ -105,7 +107,8 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={!dirty || mutation.isPending}
|
||||
disabled={!dirty || mutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
Save prefixes
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Blocklist } from "@/lib/types";
|
||||
import { sameSet, toggleSource } from "./sourceSet";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { buttonClass, focusRing, primaryButtonClass } from "@/ui/classes";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
interface Props {
|
||||
groupId: number;
|
||||
@@ -16,6 +17,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
||||
const sources = useQuery(groupSourcesQuery(groupId));
|
||||
const mutation = useMutation(groupSourcesPutMutation(queryClient));
|
||||
const [selected, setSelected] = useState<number[] | null>(null);
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
if (sources.isPending) {
|
||||
return (
|
||||
@@ -58,7 +60,8 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || mutation.isPending}
|
||||
disabled={!dirty || mutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
onClick={() =>
|
||||
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import GroupSourcesEditor from "./GroupSourcesEditor";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { focusRing, primaryButtonClass, smallButtonClass, smallInputClass } from "@/ui/classes";
|
||||
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
|
||||
|
||||
@@ -23,6 +24,7 @@ export default function GroupsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const createMutation = useMutation(groupCreateMutation(queryClient));
|
||||
const [newName, setNewName] = useState("");
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
return (
|
||||
<section>
|
||||
@@ -44,9 +46,15 @@ export default function GroupsPage() {
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(event) => setNewName(event.target.value)}
|
||||
disabled={readOnly}
|
||||
className={smallInputClass}
|
||||
/>
|
||||
<button type="submit" disabled={createMutation.isPending} className={primaryButtonClass}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
@@ -69,6 +77,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isDefault = group.id === DEFAULT_GROUP_ID;
|
||||
const readOnly = useReadOnlyConfig();
|
||||
const lockNote = isDefault ? DEFAULT_GROUP_NOTE : readOnly ? READ_ONLY_HINT : undefined;
|
||||
|
||||
return (
|
||||
<li className="rounded border border-zinc-200 p-4 dark:border-zinc-700">
|
||||
@@ -94,7 +104,12 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
||||
className={smallInputClass}
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" disabled={updateMutation.isPending} className={groupButtonClass}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={groupButtonClass}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
@@ -115,7 +130,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={group.safe_search}
|
||||
disabled={updateMutation.isPending}
|
||||
disabled={updateMutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={focusRing}
|
||||
onChange={(event) =>
|
||||
updateMutation.mutate({
|
||||
@@ -138,8 +154,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
||||
{!renaming && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDefault}
|
||||
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
|
||||
disabled={isDefault || readOnly}
|
||||
title={lockNote}
|
||||
onClick={() => {
|
||||
setName(group.name);
|
||||
setRenaming(true);
|
||||
@@ -168,8 +184,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDefault}
|
||||
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
|
||||
disabled={isDefault || readOnly}
|
||||
title={lockNote}
|
||||
onClick={() => setConfirming(true)}
|
||||
className={`${groupButtonClass} text-red-700 dark:text-red-400`}
|
||||
>
|
||||
|
||||
@@ -17,18 +17,21 @@ import {
|
||||
rowButtonClass,
|
||||
tableWrapClass,
|
||||
} from "@/ui/classes";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
|
||||
|
||||
function RecordForm({
|
||||
initial,
|
||||
busy,
|
||||
readOnly,
|
||||
error,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: LocalRecord;
|
||||
busy: boolean;
|
||||
readOnly: boolean;
|
||||
error: unknown;
|
||||
onSubmit: (input: LocalRecordInput) => void;
|
||||
onCancel: () => void;
|
||||
@@ -109,7 +112,12 @@ function RecordForm({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={busy} className={largePrimaryButtonClass}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={largePrimaryButtonClass}
|
||||
>
|
||||
{busy ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className={largeButtonClass}>
|
||||
@@ -132,18 +140,31 @@ export default function RecordsTab() {
|
||||
remove: localRecordDeleteMutation,
|
||||
confirmDelete: (record) => `Delete record "${record.name}"?`,
|
||||
});
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-500">Answers served directly for LAN names. Changes apply live.</p>
|
||||
<button type="button" onClick={() => openForm({ mode: "create" })} className={largePrimaryButtonClass}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openForm({ mode: "create" })}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={largePrimaryButtonClass}
|
||||
>
|
||||
Add record
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={remove.error} />
|
||||
{form?.mode === "create" && (
|
||||
<RecordForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
|
||||
<RecordForm
|
||||
busy={create.isPending}
|
||||
readOnly={readOnly}
|
||||
error={create.error}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={closeForm}
|
||||
/>
|
||||
)}
|
||||
<div className={tableWrapClass}>
|
||||
<table className="w-full text-left text-sm">
|
||||
@@ -181,6 +202,7 @@ export default function RecordsTab() {
|
||||
<RecordForm
|
||||
initial={record}
|
||||
busy={update.isPending}
|
||||
readOnly={readOnly}
|
||||
error={update.error}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={closeForm}
|
||||
@@ -196,15 +218,18 @@ export default function RecordsTab() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openForm({ mode: "edit", entity: record })}
|
||||
className={rowButtonClass}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={`${rowButtonClass} disabled:opacity-50`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(record)}
|
||||
disabled={remove.isPending}
|
||||
className={`${rowButtonClass} text-red-600 dark:text-red-400`}
|
||||
disabled={remove.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -17,16 +17,19 @@ import {
|
||||
rowButtonClass,
|
||||
tableWrapClass,
|
||||
} from "@/ui/classes";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
function ZoneForm({
|
||||
initial,
|
||||
busy,
|
||||
readOnly,
|
||||
error,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: ForwardZone;
|
||||
busy: boolean;
|
||||
readOnly: boolean;
|
||||
error: unknown;
|
||||
onSubmit: (input: ForwardZoneInput) => void;
|
||||
onCancel: () => void;
|
||||
@@ -70,7 +73,12 @@ function ZoneForm({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={busy} className={largePrimaryButtonClass}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={largePrimaryButtonClass}
|
||||
>
|
||||
{busy ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className={largeButtonClass}>
|
||||
@@ -93,6 +101,7 @@ export default function ZonesTab() {
|
||||
remove: forwardZoneDeleteMutation,
|
||||
confirmDelete: (zone) => `Delete forward zone "${zone.zone}"?`,
|
||||
});
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -100,13 +109,25 @@ export default function ZonesTab() {
|
||||
<p className="text-sm text-zinc-500">
|
||||
Names under these zones go to their own resolver. Changes apply live.
|
||||
</p>
|
||||
<button type="button" onClick={() => openForm({ mode: "create" })} className={largePrimaryButtonClass}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openForm({ mode: "create" })}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={largePrimaryButtonClass}
|
||||
>
|
||||
Add zone
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={remove.error} />
|
||||
{form?.mode === "create" && (
|
||||
<ZoneForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
|
||||
<ZoneForm
|
||||
busy={create.isPending}
|
||||
readOnly={readOnly}
|
||||
error={create.error}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={closeForm}
|
||||
/>
|
||||
)}
|
||||
<div className={tableWrapClass}>
|
||||
<table className="w-full text-left text-sm">
|
||||
@@ -138,6 +159,7 @@ export default function ZonesTab() {
|
||||
<ZoneForm
|
||||
initial={zone}
|
||||
busy={update.isPending}
|
||||
readOnly={readOnly}
|
||||
error={update.error}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={closeForm}
|
||||
@@ -151,15 +173,18 @@ export default function ZonesTab() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openForm({ mode: "edit", entity: zone })}
|
||||
className={rowButtonClass}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={`${rowButtonClass} disabled:opacity-50`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(zone)}
|
||||
disabled={remove.isPending}
|
||||
className={`${rowButtonClass} text-red-600 dark:text-red-400`}
|
||||
disabled={remove.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from
|
||||
import type { Rule, RuleAction, RuleKind } from "@/lib/types";
|
||||
import { defaultGroupId } from "@/lib/defaultGroup";
|
||||
import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
export default function RulesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -19,6 +20,7 @@ export default function RulesPage() {
|
||||
const [kind, setKind] = useState<RuleKind>("exact");
|
||||
const [action, setAction] = useState<RuleAction>("block");
|
||||
const [groupId, setGroupId] = useState(() => defaultGroupId(groups));
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -77,7 +79,8 @@ export default function RulesPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteRule(rule)}
|
||||
disabled={remove.isPending}
|
||||
disabled={remove.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={dangerLinkButtonClass}
|
||||
>
|
||||
Delete
|
||||
@@ -154,7 +157,12 @@ export default function RulesPage() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" disabled={create.isPending} className={primaryButtonClass}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={create.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
{create.isPending ? "Creating…" : "Create rule"}
|
||||
</button>
|
||||
<InlineError error={create.error} />
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useAuthority } from "./authority";
|
||||
|
||||
/**
|
||||
* File authority is a standing condition, not an event, so this banner has no
|
||||
* dismiss button: it stays up for as long as the process runs from a file.
|
||||
*/
|
||||
export default function ReadOnlyConfigBanner() {
|
||||
const authority = useAuthority();
|
||||
if (authority?.mode !== "managed_file") return null;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100"
|
||||
>
|
||||
Configuration is managed by <code className="font-mono">{authority.path}</code>. Edit the file and restart
|
||||
nxdns to change it; the server rejects edits made here.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { settingsPutMutation, settingsQuery } from "@/lib/queries";
|
||||
import { buildSettingsPatch } from "@/lib/settingsDiff";
|
||||
import type { Settings, SettingsPatch } from "@/lib/types";
|
||||
import { raiseRestartBanner } from "./restartBanner";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority";
|
||||
import { focusRing } from "@/ui/classes";
|
||||
|
||||
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */
|
||||
@@ -242,7 +243,8 @@ export default function SettingsPage() {
|
||||
);
|
||||
});
|
||||
const patch = buildSettingsPatch(baseline, edited, password === "" ? undefined : password);
|
||||
const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending;
|
||||
const readOnly = useReadOnlyConfig();
|
||||
const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending || readOnly;
|
||||
|
||||
function setField(section: keyof Settings, key: string, value: unknown): void {
|
||||
setEdited((prev) => ({
|
||||
@@ -273,7 +275,7 @@ export default function SettingsPage() {
|
||||
Changes are validated as a whole; every setting requires a restart to take effect.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="mt-4 max-w-3xl">
|
||||
<fieldset disabled={mutation.isPending} className="space-y-6">
|
||||
<fieldset disabled={mutation.isPending || readOnly} className="space-y-6">
|
||||
{SECTIONS.map(({ section, title, fields }) => (
|
||||
<fieldset key={section} className="rounded border border-zinc-200 p-4 dark:border-zinc-800">
|
||||
<legend className="px-1 text-sm font-semibold">{title}</legend>
|
||||
@@ -339,6 +341,7 @@ export default function SettingsPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saveDisabled}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={`rounded bg-blue-600 px-4 py-1.5 text-sm font-medium text-white ${focusRing} disabled:bg-zinc-300 disabled:text-zinc-500 dark:disabled:bg-zinc-800`}
|
||||
>
|
||||
{mutation.isPending ? "Saving…" : "Save"}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import type { Authority, Settings, SettingsEnvelope } from "@/lib/types";
|
||||
|
||||
// One file for the whole file-mode sweep: the settings envelope is the only
|
||||
// discovery mechanism, so every page test needs the same stubbed envelope.
|
||||
|
||||
const CONFIG_PATH = "/etc/nxdns/config.zon";
|
||||
|
||||
const DATABASE: Authority = { mode: "database", path: null, reconciled_at: null };
|
||||
const MANAGED_FILE: Authority = { mode: "managed_file", path: CONFIG_PATH, reconciled_at: 1754899200 };
|
||||
|
||||
function baseSettings(): Settings {
|
||||
return {
|
||||
upstream: { attempt_timeout_ms: 2500, 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 },
|
||||
blocking: { response: "zero", ttl: 300 },
|
||||
cache: { size: 10000, negative_ttl_max: 300 },
|
||||
web: {
|
||||
enabled: true,
|
||||
bind: "127.0.0.1",
|
||||
port: 8080,
|
||||
session_ttl_hours: 24,
|
||||
api_rate_limit_per_min: 60,
|
||||
api_localhost_exempt: true,
|
||||
sse_max_connections_per_ip: 2,
|
||||
trusted_proxies: "",
|
||||
auth_enabled: true,
|
||||
},
|
||||
doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" },
|
||||
dot_server: { enabled: false, bind: "0.0.0.0", port: 853, cert_path: "", key_path: "" },
|
||||
edns: { ecs_mode: "strip" },
|
||||
logging: {
|
||||
level: "info",
|
||||
retention_days: 30,
|
||||
query_log_buffer_max: 10000,
|
||||
hide_domains: false,
|
||||
hide_client_ips: false,
|
||||
output: "stderr",
|
||||
file_path: "",
|
||||
max_size_mb: 50,
|
||||
max_files: 3,
|
||||
},
|
||||
disk: { min_free_mb: 100, warn_free_mb: 500 },
|
||||
blocklist_update: { enabled: true, interval_hours: 24 },
|
||||
};
|
||||
}
|
||||
|
||||
function envelope(authority: Authority): SettingsEnvelope {
|
||||
return { settings: baseSettings(), restart_required: [], authority };
|
||||
}
|
||||
|
||||
const GROUPS = {
|
||||
groups: [
|
||||
{ id: 1, name: "default", safe_search: false },
|
||||
{ id: 2, name: "kids", safe_search: true },
|
||||
],
|
||||
};
|
||||
|
||||
const BLOCKLISTS = {
|
||||
blocklists: [
|
||||
{
|
||||
id: 1,
|
||||
url: "https://example.com/ads.txt",
|
||||
name: "Ads",
|
||||
enabled: true,
|
||||
is_suggested: false,
|
||||
last_updated: null,
|
||||
domain_count: 100,
|
||||
wildcard_count: 0,
|
||||
skipped_regex_count: 0,
|
||||
checksum: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const RULES = {
|
||||
rules: [
|
||||
{
|
||||
id: 1,
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
pattern: "ads.example.com",
|
||||
kind: "exact",
|
||||
action: "block",
|
||||
created_at: 1700000000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const CLIENTS = {
|
||||
clients: [
|
||||
{
|
||||
id: 1,
|
||||
ip: "192.168.1.10",
|
||||
name: "laptop",
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
hand_edited: true,
|
||||
first_seen: 1700000000,
|
||||
last_seen: 1700003600,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
ip: "192.168.1.11",
|
||||
name: "",
|
||||
group_id: 2,
|
||||
group: "kids",
|
||||
hand_edited: false,
|
||||
first_seen: 1700000000,
|
||||
last_seen: 1700007200,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const PREFIXES = {
|
||||
client_prefixes: [{ id: 1, prefix: "192.168.1.0/24", group_id: 2, group: "kids", priority: 100 }],
|
||||
};
|
||||
|
||||
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||
|
||||
const BASE: Record<string, unknown> = {
|
||||
"GET /api/version": VERSION,
|
||||
"GET /api/groups": GROUPS,
|
||||
"GET /api/blocklists": BLOCKLISTS,
|
||||
"GET /api/rules": RULES,
|
||||
"GET /api/clients": CLIENTS,
|
||||
"GET /api/client-prefixes": PREFIXES,
|
||||
};
|
||||
|
||||
function stubFetch(map: Record<string, unknown>) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const key = `${init?.method ?? "GET"} ${String(input)}`;
|
||||
const payload = map[key];
|
||||
if (payload === undefined) {
|
||||
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
|
||||
}
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function renderAt(route: string, heading: string, authority: Authority) {
|
||||
stubFetch({ ...BASE, "GET /api/settings": envelope(authority) });
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: [route] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
await screen.findByRole("heading", { name: heading });
|
||||
}
|
||||
|
||||
function button(name: string): HTMLButtonElement {
|
||||
return screen.getByRole("button", { name }) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function clientRow(ip: string): HTMLElement {
|
||||
const row = screen.getByText(ip).closest("tr");
|
||||
if (row === null) throw new Error(`no client row for ${ip}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("the banner names the managed file in file mode", async () => {
|
||||
await renderAt("/rules", "Rules", MANAGED_FILE);
|
||||
|
||||
const banner = await screen.findByText(/configuration is managed by/i);
|
||||
expect(banner.textContent).toContain(CONFIG_PATH);
|
||||
expect(banner.textContent).toMatch(/restart/i);
|
||||
expect(banner.closest('[role="status"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the banner is absent in database mode", async () => {
|
||||
await renderAt("/rules", "Rules", DATABASE);
|
||||
|
||||
await screen.findByRole("button", { name: "Create rule" });
|
||||
expect(screen.queryByText(/configuration is managed by/i)).toBeNull();
|
||||
});
|
||||
|
||||
function kidsRow(): HTMLElement {
|
||||
const row = screen.getByText("kids").closest("li");
|
||||
if (row === null) throw new Error("no row for group kids");
|
||||
return row;
|
||||
}
|
||||
|
||||
test("file mode disables the Groups create and delete controls", async () => {
|
||||
await renderAt("/groups", "Groups", MANAGED_FILE);
|
||||
await screen.findByText(/configuration is managed by/i);
|
||||
|
||||
expect(button("Create").disabled).toBe(true);
|
||||
expect((within(kidsRow()).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((within(kidsRow()).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("database mode leaves the Groups create and delete controls enabled", async () => {
|
||||
await renderAt("/groups", "Groups", DATABASE);
|
||||
await screen.findByRole("button", { name: "Create" });
|
||||
|
||||
expect(button("Create").disabled).toBe(false);
|
||||
expect((within(kidsRow()).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((within(kidsRow()).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("file mode disables the Rules create and delete controls", async () => {
|
||||
await renderAt("/rules", "Rules", MANAGED_FILE);
|
||||
await screen.findByText(/configuration is managed by/i);
|
||||
|
||||
expect(button("Create rule").disabled).toBe(true);
|
||||
expect(button("Delete").disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("database mode leaves the Rules create and delete controls enabled", async () => {
|
||||
await renderAt("/rules", "Rules", DATABASE);
|
||||
await screen.findByRole("button", { name: "Create rule" });
|
||||
|
||||
expect(button("Create rule").disabled).toBe(false);
|
||||
expect(button("Delete").disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("file mode keeps delete live for an observed client and blocks it for a declared one", async () => {
|
||||
await renderAt("/clients", "Clients", MANAGED_FILE);
|
||||
await screen.findByText(/configuration is managed by/i);
|
||||
|
||||
const declared = clientRow("192.168.1.10");
|
||||
const observed = clientRow("192.168.1.11");
|
||||
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((within(observed).getByRole("button", { name: "Edit" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("file mode leaves the blocklist refresh button enabled", async () => {
|
||||
await renderAt("/blocklists", "Blocklists", MANAGED_FILE);
|
||||
await screen.findByText(/configuration is managed by/i);
|
||||
|
||||
expect(button("Update now").disabled).toBe(false);
|
||||
expect(button("Add source").disabled).toBe(true);
|
||||
expect((screen.getByRole("checkbox", { name: "Ads enabled" }) as HTMLInputElement).disabled).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { settingsQuery } from "@/lib/queries";
|
||||
import type { Authority } from "@/lib/types";
|
||||
|
||||
/** The one-line explanation on every control file authority takes away. */
|
||||
export const READ_ONLY_HINT = "Configuration is managed by a file; edit the file and restart nxdns.";
|
||||
|
||||
/**
|
||||
* The running server's configuration authority, read from the settings
|
||||
* envelope — the only route that carries it. `undefined` until that query
|
||||
* resolves. Every page may call this: it is the shared `["settings"]` key, so
|
||||
* the shell's own subscription serves them all from cache.
|
||||
*/
|
||||
export function useAuthority(): Authority | undefined {
|
||||
return useQuery(settingsQuery()).data?.authority;
|
||||
}
|
||||
|
||||
/**
|
||||
* True only once the server has said a file owns the configuration. While the
|
||||
* mode is unknown nothing is disabled — the 403 is the enforcement, this is
|
||||
* the courtesy.
|
||||
*/
|
||||
export function useReadOnlyConfig(): boolean {
|
||||
return useAuthority()?.mode === "managed_file";
|
||||
}
|
||||
@@ -2,18 +2,21 @@ import { useState, type FormEvent } from "react";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import type { Upstream, UpstreamInput } from "@/lib/types";
|
||||
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
|
||||
import { READ_ONLY_HINT } from "@/features/settings/authority";
|
||||
|
||||
const DEFAULT_PRIORITY = "100";
|
||||
|
||||
interface UpstreamFormProps {
|
||||
initial?: Upstream;
|
||||
busy: boolean;
|
||||
/** File authority: the server answers 403, so the submit stays down. */
|
||||
readOnly: boolean;
|
||||
error: Error | null;
|
||||
onSubmit: (input: UpstreamInput) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel }: UpstreamFormProps) {
|
||||
export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit, onCancel }: UpstreamFormProps) {
|
||||
const [url, setUrl] = useState(initial?.url ?? "");
|
||||
const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority));
|
||||
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
|
||||
@@ -97,7 +100,12 @@ export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel
|
||||
Enabled
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="submit" disabled={busy} className={primaryButtonClass}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
{initial === undefined ? "Add upstream" : "Save changes"}
|
||||
</button>
|
||||
{onCancel !== undefined && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Upstream, UpstreamInput } from "@/lib/types";
|
||||
import { raiseRestartBanner } from "../settings/restartBanner";
|
||||
import UpstreamForm from "./UpstreamForm";
|
||||
import { dangerLinkButtonClass, focusRing, linkButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
export default function UpstreamsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -16,6 +17,7 @@ export default function UpstreamsPage() {
|
||||
const save = useMutation(upstreamUpdateMutation(queryClient));
|
||||
const toggle = useMutation(upstreamUpdateMutation(queryClient));
|
||||
const remove = useMutation(upstreamDeleteMutation(queryClient));
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
async function submitForm(input: UpstreamInput) {
|
||||
if (editing === null) {
|
||||
@@ -84,7 +86,8 @@ export default function UpstreamsPage() {
|
||||
type="checkbox"
|
||||
aria-label={`${u.url} enabled`}
|
||||
checked={u.enabled}
|
||||
disabled={toggle.isPending}
|
||||
disabled={toggle.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
onChange={() => toggleEnabled(u)}
|
||||
className={focusRing}
|
||||
/>
|
||||
@@ -95,14 +98,17 @@ export default function UpstreamsPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(u)}
|
||||
className={linkButtonClass}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={`${linkButtonClass} disabled:opacity-50`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteUpstream(u)}
|
||||
disabled={remove.isPending}
|
||||
disabled={remove.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={dangerLinkButtonClass}
|
||||
>
|
||||
Delete
|
||||
@@ -121,6 +127,7 @@ export default function UpstreamsPage() {
|
||||
key={editing?.id ?? "add"}
|
||||
initial={editing ?? undefined}
|
||||
busy={editing === null ? create.isPending : save.isPending}
|
||||
readOnly={readOnly}
|
||||
error={formError}
|
||||
onSubmit={submitForm}
|
||||
onCancel={editing === null ? undefined : () => setEditing(null)}
|
||||
|
||||
@@ -445,6 +445,11 @@ export const sample_post_pause: PauseState = {
|
||||
};
|
||||
|
||||
export const sample_get_settings: SettingsEnvelope = {
|
||||
authority: {
|
||||
mode: "database",
|
||||
path: null,
|
||||
reconciled_at: null,
|
||||
},
|
||||
restart_required: [
|
||||
"upstream.attempt_timeout_ms",
|
||||
"upstream.read_timeout_ms",
|
||||
@@ -563,6 +568,11 @@ export const sample_get_settings: SettingsEnvelope = {
|
||||
};
|
||||
|
||||
export const sample_put_settings: SettingsEnvelope = {
|
||||
authority: {
|
||||
mode: "database",
|
||||
path: null,
|
||||
reconciled_at: null,
|
||||
},
|
||||
restart_required: [
|
||||
"upstream.attempt_timeout_ms",
|
||||
"upstream.read_timeout_ms",
|
||||
|
||||
@@ -380,9 +380,23 @@ export interface Settings {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which configuration source the running process obeys. `path` and
|
||||
* `reconciled_at` are non-null only under `managed_file`: the file the process
|
||||
* loaded, and the epoch second at which it loaded it. Authority lives in the
|
||||
* invocation, never in the database, so this is the only place the UI can read
|
||||
* it — and it rides an authenticated route, never the open ones.
|
||||
*/
|
||||
export interface Authority {
|
||||
mode: "database" | "managed_file";
|
||||
path: string | null;
|
||||
reconciled_at: number | null;
|
||||
}
|
||||
|
||||
export interface SettingsEnvelope {
|
||||
settings: Settings;
|
||||
restart_required: string[];
|
||||
authority: Authority;
|
||||
}
|
||||
|
||||
export interface TlsListenerPatch {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/auth/store";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { versionQuery } from "@/lib/queries";
|
||||
import PauseWidget from "../features/pause/PauseWidget";
|
||||
import ReadOnlyConfigBanner from "../features/settings/ReadOnlyConfigBanner";
|
||||
import RestartBanner from "../features/settings/RestartBanner";
|
||||
import { buttonClass, focusRing } from "@/ui/classes";
|
||||
|
||||
@@ -112,6 +113,7 @@ export default function AppShell() {
|
||||
</div>
|
||||
</header>
|
||||
<RestartBanner />
|
||||
<ReadOnlyConfigBanner />
|
||||
{drawerOpen && (
|
||||
<div id="mobile-nav" className="border-b border-zinc-200 md:hidden dark:border-zinc-800">
|
||||
<nav aria-label="Main" className="px-2 py-2">
|
||||
|
||||
Reference in New Issue
Block a user