5 Commits
Author SHA1 Message Date
mokhtar 79a161e73a spec milestone 21: re-verify anchors against m20, record authority-mode interaction
Gates / test (push) Successful in 1m27s
Gates / test-aarch64 (push) Successful in 5m56s
Gates / frontend (push) Successful in 49s
Gates / package (push) Successful in 3m54s
Gates / container (push) Failing after 2m20s
CI / gates (push) Failing after 14m27s
2026-08-11 23:37:20 +02:00
mokhtar d76afc147a milestone 20: declarative configuration for iac 2026-08-11 23:31:40 +02:00
mokhtar 2f29121e27 spec milestone 21: abp list exceptions and operator regex rules 2026-08-11 19:03:58 +02:00
mokhtar 9aa396a65a spec milestone 20: declarative configuration for iac 2026-08-11 18:49:03 +02:00
mokhtar 929626b1dc milestone 14: verify-a-release walkthrough run against v0.0.1, acceptance closed 2026-08-09 01:35:43 +02:00
77 changed files with 8020 additions and 2043 deletions
+40
View File
@@ -10,6 +10,46 @@ subject rarely does.
## [Unreleased] ## [Unreleased]
### 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 ## [0.0.1] - 2026-08-09
First release. Everything below is new. First release. Everything below is new.
+19 -9
View File
@@ -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 `StateDirectory` and `LogsDirectory` settings make systemd create them on first
start, `/var/lib/nxdns` at mode 0700 owned by `nxdns`. 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 nxdns will not start with nothing to forward to. Write `/etc/nxdns/config.zon`:
to forward to. Write `/etc/nxdns/config.zon`:
```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 something to fix and printed every problem it found. The upstream probe sends a
real query, so this needs working DNS on the host. real query, so this needs working DNS on the host.
The file seeds the database once. From the second start onwards it is ignored Load it into the database:
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 ```sh
can delete the file: 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 ```sh
rm /etc/nxdns/config.zon 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 ## 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 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. availability and disk state without a login.
## More ## More
+17 -6
View File
@@ -9,6 +9,8 @@ you what asked for what.
- Blocklist filtering: subscribe to hosts/domain lists, plus your own allow - Blocklist filtering: subscribe to hosts/domain lists, plus your own allow
and block rules with wildcard support (`*.example.com`) 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 - Per-client policy groups: different filtering for the kids' tablet and
your workstation your workstation
- Local DNS records and conditional forwarding for internal zones - 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) ## 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 first release will make possible; it does not work today, because there is no
image in the registry to pull: 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 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 name it — `NXDNS_IMAGE=nxdns docker compose up -d` — as
[docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md) [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 describes. DNS is on port 53, the web UI on <http://localhost:8080>.
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` / The compose file runs `nxdns run --config=/etc/nxdns/config.zon`, which makes
`nxdns import`. Full install instructions, including the systemd path and that file the configuration: every start reconciles the database onto it, and
the Pi 5 recipe, are in 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-systemd.md](docs/how-to/install-with-systemd.md) and
[docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md). [docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md).
+11 -4
View File
@@ -6,10 +6,17 @@ services:
# NXDNS_IMAGE=nxdns. # NXDNS_IMAGE=nxdns.
image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-latest}} image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-latest}}
restart: unless-stopped restart: unless-stopped
# First boot needs ./etc-nxdns/config.zon with a `default` group and at # `run --config` makes the file the sole source of configuration: nxdns
# least one enabled upstream, or the container exits with code 2. The file # reconciles the database onto ./etc-nxdns/config.zon at every start, and
# seeds the database once; after that the database is the truth and the # rejects configuration writes from the admin UI. Edit the file and restart
# file is ignored. # 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: volumes:
- ./etc-nxdns:/etc/nxdns:ro - ./etc-nxdns:/etc/nxdns:ro
- nxdns-data:/var/lib/nxdns - nxdns-data:/var/lib/nxdns
+8
View File
@@ -16,6 +16,11 @@ StateDirectoryMode=0700
LogsDirectory=nxdns LogsDirectory=nxdns
ConfigurationDirectory=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). # Port 53 (and 443/853 when the DoH/DoT listeners are enabled).
AmbientCapabilities=CAP_NET_BIND_SERVICE AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE
@@ -44,6 +49,9 @@ SystemCallArchitectures=native
Restart=on-failure Restart=on-failure
RestartSec=2 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] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+1 -1
View File
@@ -65,7 +65,7 @@ are inspectable.
- [explanation/architecture.md](explanation/architecture.md) — the module map - [explanation/architecture.md](explanation/architecture.md) — the module map
and the design it comes from. and the design it comes from.
- [explanation/configuration-model.md](explanation/configuration-model.md) — why - [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) - [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 — why the targets exist, why CI does not gate on them, and what the hermetic
tests do and do not prove. tests do and do not prove.
+12 -6
View File
@@ -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/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/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/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/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`). | | `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 │ injects std.Io + collaborators
┌──────────────────────┴───────────────────────┐ ┌──────────────────────┴───────────────────────┐
│ server/ web/ upstream/ storage/ │ I/O edge │ server/ web/ upstream/ storage/ │ I/O edge
│ platform/ config/{import,export,bootstrap} │ │ platform/ config/{loader,reconcile,import} │
├──────────────────────────────────────────────┤ ├──────────────────────────────────────────────┤
│ dns/ filter/* local/* cache/ │ pure core: │ dns/ filter/* local/* cache/ │ pure core:
│ config/{model,validate} │ bytes in, bytes out │ 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 Two databases with opposite contracts, in one data directory (see
[reference/files-and-directories.md](../reference/files-and-directories.md)). [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 an ordered list of steps, step 1 being the verbatim DDL from
`config_schema.zig`, each applied inside one transaction. `nxdns import` `config_schema.zig`, each applied inside one transaction. `nxdns import`
replaces the whole content atomically under `BEGIN IMMEDIATE`, so a failed replaces the whole content atomically under `BEGIN IMMEDIATE`, so a failed
import changes nothing; `nxdns export` renders it back as canonical ZON, import changes nothing; `nxdns export` renders it back as canonical ZON,
byte-identical across round trips. A config file seeds this database exactly byte-identical across round trips.
once at first start. Why it works that way is
[configuration-model.md](configuration-model.md). 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 **`querylog.db` is expendable.** It is never migrated. Its schema carries a
fingerprint derived from the DDL text, and at open, a missing, corrupt, fingerprint derived from the DDL text, and at open, a missing, corrupt,
+183 -146
View File
@@ -1,8 +1,9 @@
# The configuration model # The configuration model
nxdns is configured two ways — a ZON file and a web UI — and only one of them 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 can be the truth at a time. This page explains how that choice is made, what
that leaves the fewest ways to lose an operator's work. each mode is for, and why the design leaves the fewest ways to lose an
operator's work.
For the fields themselves see For the fields themselves see
[reference/configuration.md](../reference/configuration.md); for the commands [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 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 the database is the truth
`nxdns run` only while the database is still empty: if the file exists and the nxdns run --config /etc/nxdns/config.zon the file is the truth
database holds no configuration, it is imported. Once the import has put rows ```
in, the file is not opened again.
The condition is the state of the database, not a one-shot flag. A `run` whose That is the entire selection mechanism. There is no mode setting, no default
seed fails — unreadable file, parse error, failed validation, a constraint file path, and nothing recorded in the database about which mode last wrote it.
violation inside the import transaction — leaves the database empty, so the A `config.zon` that exists but that no invocation names changes nothing at all.
next `run` reads the file again. That is what makes fixing a typo and starting
again work.
`src/config/bootstrap.zig` is that policy and nothing else — a wrapper over Two properties fall out of that, and both were chosen on purpose.
the same import path `nxdns import` uses. Three outcomes:
- no file: the database is used as it is; **An operator can read `ExecStart` and know which authority is live.** The
- file present, database empty: seed it; alternative — probe a well-known path, and behave differently depending on
- file present, database already configured: skip, without reading the file. 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, **A path already expresses a two-state choice, so a mode flag beside it would
with every problem printed. nxdns never falls back to silent defaults over a be redundant and worse.** An earlier draft had `--config-source=db|file`. A mode
file an operator wrote — a resolver that boots "successfully" with a flag next to a path flag manufactures combinations that cannot mean anything —
configuration nobody chose is the worst outcome available, because it looks a path with no mode, a mode with no path — and each one then needs a pairing
like it worked. 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 A fresh install in this mode starts from an empty database, which fails
be an SSH session and a restart, which defeats the reason the UI exists: the validation on its own terms — there is nowhere to forward a query to — and says
household member who wants to unblock one domain is not going to edit ZON. 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 nxdns run failed: NoUsableUpstreams
silently ignores the file. Both are silent, and both destroy work someone run `nxdns check` to see the configuration in full
intended to keep. There is no merge rule that fixes this, because the system load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
cannot know which of two conflicting statements is the newer intention. ```
So the file's authority ends the moment the database has content. Editing ## File mode
`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".
The emptiness check is a real query over the content tables, not a flag: the `nxdns run --config FILE`. The file is the sole declarative source, and the
database counts as configured when a content table holds rows, or when the database becomes the runtime substrate: every start reads the file, validates
default group has been altered. What it measures is intent, not activity, and it, converges the database onto it, and serves from there. Configuration writes
the client table is where those two come apart. Clients are auto-materialised through the API are refused with a 403.
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.
Counting traffic here would have been a quiet trap: a server that resolved one This is the mode for a file kept in git and pushed by Ansible. What it buys is
name would have declared itself configured and ignored a seed file placed that the deployed file is what is running — not "was imported once", not
afterwards, and the operator would have had no line of output saying why. "was imported unless someone clicked something since".
The same distinction survives an import. `import` replaces the content in one Three properties make it usable rather than merely correct.
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.
An address the imported file names belongs to the file — the operator's **It fails closed.** A file that is missing, unreadable, unparseable, oversized
statement wins over the discovered row — with one carve-out. First-seen and or invalid stops the start. nxdns never falls back to the database, because a
last-seen are not configuration: they record when a device was heard from, the fallback turns a deploy typo into a configuration that is silently months old
configuration model has no field for either, and an import is not a query. So and looks fine. That failure is exit 2, so `nxdns check --config FILE` is a real
they follow the address rather than the row. If the database already knew that pre-restart gate: validate the pushed file in the handler, and a typo is a
address, its two timestamps are carried onto the new row; only an address the failed deploy at noon rather than a dead resolver at the next power cut.
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 **It converges rather than replaces.** Reconciling matches rows by identity and
had just arrived — and those are exactly the devices whose history is worth writes only what differs. A source whose URL has not changed keeps its row id,
something. 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 ## The round trip
Losing the file as an editing surface would be a real loss — text is The file stays useful as an editing surface in both modes — text is diffable,
diffable, reviewable and easy to back up — so the file is kept as a reviewable and easy to back up — because `export` renders the database into the
*rendering* of the database rather than a rival to it. That is what same shape `import` and file mode read:
`export`/`import` are for:
``` ```
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 `nxdns export` writes canonical ZON: a fixed two-line header, every default
header, every default emitted, deterministic ordering from the model's field emitted, deterministic ordering from the model's field order and the
order and the repositories' `ORDER BY` clauses, and no timestamps or hostnames repositories' `ORDER BY` clauses, and no timestamps or hostnames anywhere.
anywhere. Runtime columns — first-seen, last-seen, per-source counters — are Runtime columns are absent from the configuration model on purpose, so two
absent from the configuration model on purpose, so two exports taken from a exports taken from a live, busy server are identical. `export → import →
live, busy server are identical. The round trip `export → import → export` is export` is byte-identical, and a test asserts it.
byte-identical, and a test asserts it.
Byte-stability is not cosmetic. It is what makes an exported file usable in 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 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. difference is a configuration change, never noise from when the export ran.
`nxdns import` replaces the whole database content in one transaction. Not a The output is also identical in both modes — nothing marks a file as coming
merge, not a patch: delete every content table in foreign-key-safe order, then from a file-mode box. That is deliberate, because it is what makes export the
insert what the file says — with the one exception described above. Every client adoption tool: the file you check is the file you deploy, byte for byte. The
row is lifted out first. The materialised ones are put back rather than label an operator wants is in the unit file, where they put it.
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`.
Without `--force`, import refuses a database that already holds configuration. Reconciling the same file twice produces a byte-identical database — ids,
That checksums, `created_at`, the password hash, the whole settings table — including
check runs *inside* the transaction, after the lock is taken, so it cannot be when the file is written in a non-canonical but equivalent form, such as
raced by a concurrent write. The effect is that a plain `import` can never `FD00:0:0:0:0:0:0:1` for an address stored as `fd00::1`. Anything that churns
clobber a configured server by accident, and clobbering it deliberately takes under an unchanged file is a bug in the engine by definition. That single
one visible extra word on the command line. See invariant is what forces most of the design above: matching on canonical forms,
[how-to/back-up-and-restore.md](../how-to/back-up-and-restore.md). writing only on difference, treating duplicate rule tuples as a multiset, and
verifying a password rather than re-hashing it.
## 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.
## Unknown keys, and why the asymmetry is deliberate ## 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. in the **file** is a hard error.
They are different situations. A settings row the running binary does not 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 recognise is almost always an older binary reading a database written by a newer
newer one — a downgrade, or a rollback after a bad upgrade. Refusing to start one — a downgrade, or a rollback after a bad upgrade. Refusing to start there
there would mean a downgrade bricks the config database, and the operator would mean a downgrade bricks the config database, and the operator would have
would have to hand-edit SQLite to recover. Warning and ignoring means the to hand-edit SQLite to recover. Warning and ignoring means the downgrade works,
downgrade works, the unknown setting sits inert, and the upgrade back picks it the unknown setting sits inert, and the upgrade back picks it up again.
up again.
A key in a file, by contrast, is something a human just typed. The likeliest 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. 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 Silently ignoring it would mean the setting the operator believes they applied
was never applied — exactly the silent-divergence failure the whole model is 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 built to avoid. So the ZON parser rejects unknown fields with a line and column.
column.
The tolerance has a boundary worth stating plainly: it covers unknown *keys*, 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 not unparseable *values*. A known key whose stored text does not decode into its
its type is an error, not a warning. type is an error, not a warning.
## The password ## 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 Both fields are optional, and this is the one deliberate carve-out from
encoding, OWASP argon2id parameters) into `web.password_hash`, and the file-as-sole-truth: **a file that mentions neither leaves the stored hash
plaintext field is cleared before anything is written. There is no settings alone.**
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.
Setting both `password` and `password_hash` in one file is an error rather The reason is a trap the design walked into once. An export carries the full PHC
than a precedence rule. The two say different things about what the password string, which is long and ugly, and an operator committing that file to git will
is, and picking a winner would mean the operator's other statement was sooner or later delete the line — meaning "keep the current password". If
silently discarded. The file has to say one thing. 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` The other end of the same problem is `password = ""`. Hashing the empty string
to the new value, clear `.password_hash`, import with `--force`. The procedure produces a perfectly valid hash, so authentication would be *on* — while the
is in login handler refuses every empty password, so it could never be satisfied.
[how-to/set-up-admin-authentication.md](../how-to/set-up-admin-authentication.md). 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 ## What is not configuration
Storage paths are process arguments, not configuration fields: `--data-dir`, Storage paths are process arguments, not configuration fields: `--data-dir`,
`--config`, `--web-dev`. They cannot live in the file, because the file is `--config`, `--web-dev`. They cannot live in the file, because the file is found
found by way of them — a path that told you where to find the thing that told by way of them — a path that told you where to find the thing that told you the
you the path would be circular. They are also the settings a supervisor path would be circular. They are also the settings a supervisor (systemd,
(systemd, Docker) owns rather than the operator's policy about DNS. See Docker) owns rather than the operator's policy about DNS. See
[reference/files-and-directories.md](../reference/files-and-directories.md). [reference/files-and-directories.md](../reference/files-and-directories.md).
+106 -58
View File
@@ -1,9 +1,22 @@
# Back up and restore # Back up and restore
The configuration database is the only state worth keeping. `nxdns export` The configuration is the only state worth keeping. `nxdns export` writes it out
writes it out as a ZON file and `nxdns import` writes one back. The query log is as a ZON file and `nxdns import` writes one back. The query log is deliberately
deliberately not part of a backup: it is expendable history, and if it is not part of a backup: it is expendable history, and if it is missing it gets
missing it gets recreated empty. 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 The commands below use the scratch lab from
[enable DoH and DoT](enable-doh-and-dot.md), data directory [enable DoH and DoT](enable-doh-and-dot.md), data directory
@@ -40,13 +53,16 @@ grep password /tmp/nxdns-lab/backup.zon
``` ```
``` ```
.password = "", .password = null,
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$Gh+zg9xke6BqSVOiouRbqG50+Bs8ZGcXA6oKgs7lrKg$crTNMu5OI8yKBkp31r4+Y1OUQmLiAlH/qvsIxjBQRq4", .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 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 Without `--out` the export goes to stdout, where the file mode is your
redirect's problem: redirect's problem:
@@ -57,7 +73,7 @@ nxdns export --data-dir /tmp/nxdns-lab/data | head -10
``` ```
// nxdns configuration // nxdns configuration
// generated by `nxdns export` the database is the source of truth // generated by `nxdns export` from the running configuration
.{ .{
.upstream = .{ .upstream = .{
.attempt_timeout_ms = 2500, .attempt_timeout_ms = 2500,
@@ -75,8 +91,8 @@ exports taken minutes apart differ.
## Restore onto a fresh data directory ## Restore onto a fresh data directory
This is the normal restore: new machine, new disk, empty data directory. No This is the normal restore: new machine, new disk, empty data directory.
`--force`, because there is nothing to overwrite. Nothing to overwrite, so nothing to authorise.
```sh ```sh
nxdns import /tmp/nxdns-lab/backup.zon --data-dir /tmp/nxdns-lab/data-restored 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 ## Restore over an existing database
`import` refuses a database that already holds configuration, so a plain **Stop the server first.** `import` rewrites configuration underneath a process
`import` can never clobber a configured server by accident: 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
```sh and settings stay at their boot values until a restart.
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:
```sh ```sh
systemctl stop nxdns systemctl stop nxdns
nxdns import /var/backups/nxdns-config.zon --force nxdns import /var/backups/nxdns-config.zon
systemctl start nxdns systemctl start nxdns
``` ```
**Not verified on this host.** Those three lines are the only commands on this `import` converges the database onto the file. Rows the file still names are
page that were not run: this machine has no installed nxdns systemd unit matched and updated in place; rows it no longer names are deleted. That last
(`systemctl status nxdns` answers `Unit nxdns.service could not be found.`) and part is what a restore of an old backup does to everything added since, so it
`systemctl stop`/`start` need root. The lab equivalent below was run, and it takes a flag:
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:
```sh ```sh
nxdns export --data-dir /tmp/nxdns-lab/data --out /tmp/nxdns-lab/pre-restore.zon nxdns import /tmp/nxdns-lab/old-backup.zon --data-dir /tmp/nxdns-lab/data
# 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
``` ```
``` ```
wrote /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
imported /tmp/nxdns-lab/pre-restore.zon 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 ## Verify a backup
The round trip is byte-stable: exporting, importing and exporting again gives 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 — check` does not run them at all. Take an export before installing a new binary —
see [upgrade](upgrade.md). see [upgrade](upgrade.md).
Every command on this page was executed on this host as written, except the Every `nxdns` command on this page was executed on this host as written. The
`systemctl` block marked **Not verified on this host** above. `systemctl`, `cp`, `chown` and `chmod` lines were not: they need root and an
installed unit, and both blocks holding them say so.
+6 -4
View File
@@ -62,10 +62,12 @@ to:
} }
``` ```
Write that to `/tmp/nxdns-lab/etc/config.zon`. The configuration file seeds an Write that to `/tmp/nxdns-lab/etc/config.zon`. The lab runs
empty database and is then ignored; to change these settings on a server that `nxdns run --config`, which makes that file the configuration: every start
already has a database, edit them through the API or through reconciles the database onto it, so editing the file and restarting is how these
`export`/`import` — see [the configuration model](../explanation/configuration-model.md). 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 ## 3. Check the files before starting
+77 -31
View File
@@ -10,16 +10,29 @@ supported and is the last section of this page.
For what each configuration field means, see For what each configuration field means, see
[the configuration reference](../reference/configuration.md). [the configuration reference](../reference/configuration.md).
> Verification: the seed-file failure modes, the run and the two checks in > Verification: the failure modes, the run and the two checks in step 3 were run
> step 3 were run on the machine that wrote this page, against an image built > on the machine that wrote an earlier revision of this page, against an image
> from this checkout rather than pulled from the registry — no release is > 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, > 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: > and step 1 could not be run at all. One command was run in altered form: host
> host port 8080 was occupied here, so the run and the verification commands > port 8080 was occupied there, so the run and the verification commands in
> in step 3 were executed with the host side of the port mappings moved to > step 3 were executed with the host side of the port mappings moved to 25353
> 25353 and 28088 rather than the 53 and 8080 printed below. The container > and 28088 rather than the 53 and 8080 printed below. The container side was
> side was unchanged. See the note in step 3. The `chown` to uid 65532 needs > unchanged. See the note in step 3. The `chown` to uid 65532 needs root and was
> root and was not run. > 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 ## 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 > `docker buildx imagetools inspect --format` shape was run here against
> `alpine:3.22` on Docker Hub and printed that image's index digest. > `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 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, 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. > 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 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 ```sh
mkdir -p deploy/docker/etc-nxdns 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 **The compose file ships file mode**, with
database has nothing to forward to. The log is `command: ["run", "--config=/etc/nxdns/config.zon"]`. That file is the
`no configuration file at '/etc/nxdns/config.zon'; using the database as it is` configuration: the container reconciles its database onto it at every start, and
followed by `nxdns run failed: NoUsableUpstreams`. 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 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 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 diagnostic and exits 2 as well. A file whose only group was named `other`:
image. A seed file whose only group was named `other`:
``` ```
FAIL groups: no group named 'default'; every unknown client is assigned to it 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 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).
``` ### Database mode in Docker instead
info(config_bootstrap): no configuration file at '/etc/nxdns/config.zon'; using the database as it is
nxdns run failed: NoUsableUpstreams Drop the `command:` line from `compose.yaml` and the container runs
run `nxdns check` to see the configuration in full `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 The file is positional; add `--allow-delete` when re-running it against a
alone no longer tells them apart: read the lines above the failure, which either populated volume and the diff deletes rows. Without this step, `restart:
name the diagnostic in the file or say there was no file at all. See unless-stopped` plus exit 2 is a crash loop with no way out.
[Troubleshoot nxdns](troubleshoot.md).
> 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 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 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 > 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 > was verified is the failure it prevents — a configuration file at 0600 owned
> another uid makes the container log `nxdns run failed: AccessDenied` and > by another uid makes the container refuse to start and restart in a loop. In
> restart in a loop. See [Troubleshoot nxdns](troubleshoot.md). > 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 ## 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 and it takes the project name `docker` from that directory either way, which is
why the container is `docker-nxdns-1`. 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(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 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: Confirm it answers and that the admin interface is up:
```sh ```sh
+182 -28
View File
@@ -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 mind finding the directory already there; it adjusts the mode and ownership to
what the unit asks for. 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 nxdns will not start with nothing to forward to. Write `/etc/nxdns/config.zon`.
to forward to. Write `/etc/nxdns/config.zon`. The smallest file that starts is The smallest file that starts is one group named `default` and one enabled
one group named `default` and one enabled upstream: upstream:
```zon ```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 the time you run it. Exit 2 means `check` found something to fix and printed
every problem it found, not only the first. every problem it found, not only the first.
The file seeds the database once. From the second start onwards it is ignored Now load it into the database:
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.
Once the seed has been consumed — after step 6 confirms you can log in — the ```sh
plaintext in it is dead weight that only carries risk. The seed's nxdns import /etc/nxdns/config.zon
`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: 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 ```sh
rm /etc/nxdns/config.zon rm /etc/nxdns/config.zon
``` ```
Keep it only if you want the seed as a record of the intended starting A kept file is not a backup — `nxdns export` is (see
configuration, and if you keep it, leave it at 0640 root:nxdns. Note that a [Back up and restore](back-up-and-restore.md)), and the export carries the
kept seed is not a backup — `nxdns export` is password hash rather than the password. If you keep it, leave it at 0640
(see [Back up and restore](back-up-and-restore.md)), and the export carries the root:nxdns.
password hash rather than the password.
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 exporteditimport 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 > 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 > `/etc/nxdns` and `/var/lib/nxdns` — those two paths are the only difference
> 0644, `nxdns check --config` on it printed `OK: no problems found` with no > from the blocks above. A file written under umask 022 came out 0644,
> mode warning, and after `nxdns import` of that seed an `nxdns export` wrote > `nxdns check --config` on it printed `OK: no problems found` with no mode
> `.password = ""` next to a populated `.password_hash = > warning, `nxdns import` of it printed the migration line and `imported <path>`
> "$argon2id$v=19$..."`. The `chown`, `chmod` and `rm` lines above are the > and exited 0, and a following `nxdns export` wrote `.password = null` next to
> ordinary root-owned-file operations and were not run against a real > a populated `.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…"`. The
> `/etc/nxdns`, which this host does not have. > `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 ## 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 needs no further configuration. Port 53 is privileged, and the unit grants
`CAP_NET_BIND_SERVICE` through `AmbientCapabilities`. `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 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 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 ## 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 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. availability and disk state without a login.
> Not verified on this host as written: `<server-ip>` is a placeholder, and a > 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 > port returned 200. Only the address and the port differ from the lines
> above. > 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 ## Raspberry Pi 5
The Pi 5 is aarch64. Nothing about the procedure changes except which tarball The Pi 5 is aarch64. Nothing about the procedure changes except which tarball
+86 -21
View File
@@ -11,7 +11,7 @@ data directory is `/var/lib/nxdns` and the web port is 8080.
## 1. Set the password ## 1. Set the password
Put it in the seed configuration file, under `web`: Put it in the configuration file, under `web`:
```zon ```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` The plaintext is hashed with argon2id into `web.password_hash` and discarded. It
and discarded. It becomes no database row and appears in no log line. Setting becomes no database row and appears in no log line. Setting both `password` and
both `password` and `password_hash` in one file is refused: `password_hash` in one file is refused:
``` ```
web.password: password and password_hash are both set; ambiguity in a security setting is refused web.password: password and password_hash are both set; ambiguity in a security setting is refused
import failed: PasswordAndHashBothSet import failed: PasswordAndHashBothSet
``` ```
The seed file is read only while the database is empty. On a server that Applying that file — with `nxdns import`, or with a `nxdns run --config` start —
already has a database, use step 4 or step 5 instead. 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 ## 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): 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; 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 ## 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 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 full settings document; `password` is write-only and `password_hash` is neither
readable nor directly writable, so neither value comes back. 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 If you have lost the password, the admin interface cannot help — go through the
database instead. Export, edit, import. `nxdns export` always writes 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 anyone knowing the password. To install a new one, put it in `.password` and
clear `.password_hash`: clear `.password_hash`:
@@ -169,11 +215,22 @@ clear `.password_hash`:
nxdns export --data-dir /tmp/nxdns-lab/data --out /tmp/nxdns-lab/rekeyed.zon 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 ```zon
.password = "offline-password", .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 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 ```sh
# Ctrl-C the `nxdns run` terminal, or `kill` its pid from another shell # 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 import /tmp/nxdns-lab/rekeyed.zon --data-dir /tmp/nxdns-lab/data
nxdns run --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon nxdns run --data-dir /tmp/nxdns-lab/data
``` ```
``` ```
imported /tmp/nxdns-lab/rekeyed.zon 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 On a real install the stop and start are `systemctl stop nxdns` and
`systemctl start nxdns` around the same `import` — **not verified on this `systemctl start nxdns` around the same `import` — **not verified on this
host**, which has no installed nxdns systemd unit (`systemctl status nxdns` 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 ```sh
curl -sS -X POST http://127.0.0.1:8451/api/auth/login \ 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' -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 \ 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"}' \ -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 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 ```sh
nxdns export --data-dir /tmp/nxdns-lab/data | grep password nxdns export --data-dir /tmp/nxdns-lab/data | grep password
``` ```
``` ```
.password = "", .password = null,
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$kvlRj1tdGul3MlfbvzLncLKWirNpJRJ3howFA9/ysgg$7elW7PPQ3WXHwI4YOmOpZ/1KNEQo7ZDLRJhnYOPMjqw", .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$xqzK66LgiWGvyCmCl6ZRa3GHH0nS5qZnRgVfWmeGadc$1mafhflKFIg3vcHjJaDMAXGQiOjtym2UADZsPW1xkfw",
``` ```
`--force` is required because the database already holds configuration. See See [back up and restore](back-up-and-restore.md) for when `import` does need
[back up and restore](back-up-and-restore.md). `--allow-delete`.
## What happens with no password set ## 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 [configuration reference](../reference/configuration.md); the routes are in
the [API reference](../reference/api.md). the [API reference](../reference/api.md).
Every command on this page was executed on this host as written, except the Every command on this page was executed on this host as written, against the
`systemctl` stop and start named in step 5 and marked **not verified on this lab described at the top, except the `systemctl` stop and start named in step 5
host** there. 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
View File
@@ -37,10 +37,22 @@ checked on its first line.
**Fixes by cause.** **Fixes by cause.**
- `NoUsableUpstreams` — the database has no enabled upstream. On a fresh - `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 install in database mode this is simply an empty database, and the run says
log says `no configuration file at '/etc/nxdns/config.zon'; using the what to do about it on the next line:
database as it is`. Write the seed file and start again against the still
empty database, or `nxdns import <file> --force`. ```
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 - `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 key is unreadable, too large, unparseable, or the key does not belong to the
certificate. `run` names both paths before it exits: 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 - `BadBindAddress``dns.bind_ipv4` or `dns.bind_ipv6` is not an address of
that family. 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 **Symptom.** `nxdns run --config`, `nxdns check --config` or `nxdns import`
problem and stops with exit 2: prints the validation problem and stops with exit 2:
``` ```
FAIL groups: no group named 'default'; every unknown client is assigned to it 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 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 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 run `nxdns check` to see the configuration in full
``` ```
`NoUpstreams` from a seed file is not the same fault as `NoUsableUpstreams` `NoUpstreams` from a file is not the same fault as `NoUsableUpstreams` above:
above: the first is a file `run` refused, the second is a database `run` the first is a file `run` refused, the second is a database `run` accepted and
accepted and found empty. Both are exit 2. found empty. Both are exit 2.
**Diagnosis.** Run the same file through `check`, which reports the same **Diagnosis.** Run the same file through `check`, which reports the same
problems and exits 2: problems and exits 2:
@@ -117,11 +129,21 @@ problems and exits 2:
nxdns check --config /etc/nxdns/config.zon nxdns check --config /etc/nxdns/config.zon
``` ```
**Fix.** Correct the file the diagnostics name and start again. The database is **Fix.** Correct the file the diagnostics name and start again. Nothing was
still empty after a failed seed, so the next start re-reads the file. The exit applied — a file-mode reconcile happens in one transaction that rolls back, and
code no longer depends on which command read the file: all three of these files a failed `import` leaves the database untouched. The exit code does not depend
were run through `run`, `check` and `import` here, and every one of the nine on which command read the file: all three of these files were run through `run`,
combinations exited 2 with the same diagnostic. `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 ## `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 ## The container restarts in a loop
**Symptom.** `docker compose ps` shows the container restarting, and the log is **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.** **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 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. Exit 2 naming the configuration path means the container could not read the
The container runs as uid 65532 and `/etc/nxdns` is mounted read-only, so a file the shipped `command:` makes its configuration. The container runs as uid
file at mode 0600 owned by your own uid is unreadable to it and the container 65532 and `/etc/nxdns` is mounted read-only, so a file at mode 0600 owned by
cannot repair it. 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: **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 chmod 0600 deploy/docker/etc-nxdns/config.zon
``` ```
The 0644 path was verified here, including the recovery: after the `chmod` the The 0644 path was verified against an earlier revision of this page, including
container started and answered queries. The `chown` needs root and was not run the recovery: after the `chmod` the container started and answered queries. The
here. `chown` needs root and was not run here.
A container that exits 2 instead `nxdns run failed: NoUsableUpstreams` after `FAIL /etc/nxdns/config.zon: no such file` instead of `not readable` means there
`no configuration file at '/etc/nxdns/config.zon'` — has no seed file at all on is no configuration file at all. Create `deploy/docker/etc-nxdns/config.zon` and
a fresh volume. Create `deploy/docker/etc-nxdns/config.zon` and bring it up bring it up again; see [Install with Docker](install-with-docker.md).
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 ## 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 A generation number with nothing being blocked is a different problem: the
snapshot loaded but has no sources in it. The line snapshot loaded but has no sources in it. The line
`blocklist snapshot generation 1: 0 of 0 sources loaded` says exactly that. Add `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 ## A database stamped by a newer binary
+179 -27
View File
@@ -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 > shapes and the verification commands are covered by
> [Verify a release](verify-a-release.md), which says what was probed and how. > [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 ## 1. Take an export first
There is no downgrade path, so the export is what you fall back to: 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 command relies on the default `--data-dir /var/lib/nxdns` that a systemd
install has. 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 > Verified on this host with both paths substituted, since it has neither
> `/var/lib/nxdns` nor `/some/backup`. `SCRATCH` below is a scratch directory, > `/var/lib/nxdns` nor `/some/backup`. `SCRATCH` below is a scratch directory,
> and its `data/` was populated beforehand with `nxdns import`: > 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 NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml up -d
``` ```
Compose recreates the container against the same `nxdns-data` volume. The seed Compose recreates the container against the same `nxdns-data` volume. What
file in `etc-nxdns` is not read again; the database in the volume is the happens to the file in `etc-nxdns` depends on the `command:` in your compose
configuration. 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 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 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 > zig 0.16.0
> ``` > ```
> >
> Against a running server whose database had just been migrated and seeded, > Against a running server whose database had just taken a configuration write
> `nxdns check --data-dir` printed the uncheckpointed-log line above and exited > through the API, `nxdns check --data-dir` printed the uncheckpointed-log line
> 2, while `nxdns export --out` followed by `nxdns check --config` on the result > above and exited 2, while `nxdns export --out` followed by
> exited 0 with `OK: no problems found`. The `dig` line was not run in this > `nxdns check --config` on the result exited 0 with `OK: no problems found`.
> round: nothing is listening on 127.0.0.1:53 here, and port 53 needs root. > 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 ## 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 That run exits 1. Recovering means importing the export you took in step 1 into
a fresh data directory with the older binary. 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 ## Changing settings, not the binary
An upgrade never re-reads `/etc/nxdns/config.zon`. After the first successful How you change a setting depends on which authority the service runs under.
seed the file is ignored, and the start log says so: `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 **In file mode**, edit the file, validate it, restart. The admin interface will
exporteditimport cycle against a stopped server: 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 exporteditimport cycle against a stopped server:
```sh ```sh
nxdns export --out config-backup.zon nxdns export --out config-backup.zon
$EDITOR config-backup.zon $EDITOR config-backup.zon
systemctl stop nxdns systemctl stop nxdns
nxdns import config-backup.zon --force nxdns import config-backup.zon
systemctl start nxdns systemctl start nxdns
``` ```
`--force` is required here. A plain `import` into a database that already holds `import` needs no flag to add rows or to edit them. It needs `--allow-delete`
configuration fails with `import failed: DatabaseNotEmpty` and exits 2, so it only when applying the file would delete rows the database holds — including the
cannot clobber a configured server by accident. What counts is what an operator case where you renamed something, since changing a group's name or an upstream's
set: client rows the DNS path materialised from traffic never trigger the URL is a delete and an insert to the engine, not an edit. The refusal names the
refusal on their own. 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 > $ nxdns import $SCRATCH/etc/config.zon --data-dir $SCRATCH/dbmode
> import failed: DatabaseNotEmpty > 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) > (exit 2)
> $ nxdns import $SCRATCH/nxdns-config.zon --data-dir $SCRATCH/data --force > $ nxdns import $SCRATCH/etc/smaller.zon --data-dir $SCRATCH/dbmode --allow-delete
> imported /…/scratchpad/nxdns-config.zon > imported /…/smaller.zon
> (exit 0) > (exit 0)
> ``` > ```
> >
> The `systemctl stop`/`start` lines around them need root and an installed > The first of those three is the additive case that needs no flag; the second
> service and were not run; `$EDITOR` is yours to run. > 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 ## Upgrading to a build of your own
+60 -106
View File
@@ -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 file is that it is signed, so a tampered mirror cannot hand you a matching
tarball and a matching checksum at the same time. tarball and a matching checksum at the same time.
> Verification: no nxdns release exists yet. The repository has no tags, no > Verification: every command on this page was run on 2026-08-09 against the
> release page and no pushed image, so nothing on this page could be run against > published `v0.0.1` release, from a clean directory, with a clean `GNUPGHOME`
> a real release asset and no command here was pointed at > holding only the key fetched from keys.openpgp.org. Every transcript below is
> `git.mial.net/mokhtar/nxdns` with any expectation of success. Substitutes were > that run's output. Where a block shows a failure — a `BAD signature`, a
> used, and every block says which one applies to it. > `FAILED` hash — the failure was produced deliberately by tampering with a
> > copy of the real file, and the surrounding text says how.
> 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.
## What a release contains ## What a release contains
@@ -94,11 +65,8 @@ want for the placeholder:
VERSION=<version> VERSION=<version>
``` ```
> Not verified against nxdns: there is no release to redirect to, so the first > Verified: the two-command form, run against this repository, printed `0.0.1`
> block prints an empty line here and every URL built from it is a 404. The > with `v0.0.1` published.
> 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`.
Pin the version in anything you script or automate. `latest` is convenient for 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. 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 position, as `releases/download/latest/`. The tarball filenames contain the
version, so this alias never saves you from knowing it for those two. 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. > Verified against `v0.0.1`: all five assets downloaded through the versioned
> Both URL forms, including the 404 for GitHub's spelling, were exercised > path, `SHA256SUMS.txt` downloaded again through the `latest` alias and hashed
> against `gitea.com/gitea/tea` on Gitea `1.27.0+dev`; the versioned path and > identical, and GitHub's spelling answered 404.
> 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.
## 3. Check the signature over `SHA256SUMS.txt` ## 3. Check the signature over `SHA256SUMS.txt`
@@ -157,11 +123,9 @@ curl -fsSL https://keys.openpgp.org/vks/v1/by-fingerprint/A2061F6AB24DF2C0E92346
gpg --import gpg --import
``` ```
> Not verified: the key is not published yet. Run on this host, that URL > Verified: the key is published, and that exact `curl | gpg --import` reported
> returned 404, and so did the `by-email` lookup for the same address. The > `key 1509B54946D08A95: public key "Mokhtar Mial (pc) <mokhtar@mial.net>"
> endpoint itself is live: the same `by-fingerprint` path returned 200 for an > imported` into a clean `GNUPGHOME`.
> 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.
Then verify: 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: Signature made Sun 09 Aug 2026 01:26:42 AM CEST
gpg: using EDDSA key 9D1EA241DAEA89E09381A21BDC27E8A3D53C32D6 gpg: using EDDSA key 019D00DF8417EBFDA5471E5EF7319CC024FB5A96
gpg: Good signature from "nxdns release signing (throwaway demonstration key) <demo@example.invalid>" [unknown] gpg: Good signature from "Mokhtar Mial (pc) <mokhtar@mial.net>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature! gpg: WARNING: This key is not certified with a trusted signature!
gpg: There is no indication that the signature belongs to the owner. 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 Primary key fingerprint: A206 1F6A B24D F2C0 E923 46FD 1509 B549 46D0 8A95
Subkey fingerprint: 9D1E A241 DAEA 89E0 9381 A21B DC27 E8A3 D53C 32D6 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 The *structure* is what to read: three lines, not one. `using EDDSA key` and
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
`Subkey fingerprint` name the signing subkey that actually made the signature; `Subkey fingerprint` name the signing subkey that actually made the signature;
`Primary key fingerprint` names the certificate it hangs off, and that is the `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 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 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: A tampered `SHA256SUMS.txt` looks like this, and exits 1:
``` ```
gpg: Signature made Fri 07 Aug 2026 10:11:12 PM CEST gpg: Signature made Sun 09 Aug 2026 01:26:42 AM CEST
gpg: using EDDSA key 9D1EA241DAEA89E09381A21BDC27E8A3D53C32D6 gpg: using EDDSA key 019D00DF8417EBFDA5471E5EF7319CC024FB5A96
gpg: BAD signature from "nxdns release signing (throwaway demonstration key) <demo@example.invalid>" [unknown] gpg: BAD signature from "Mokhtar Mial (pc) <mokhtar@mial.net>" [unknown]
``` ```
> Verified on this host. A throwaway ed25519 primary key was generated into a > Verified against `v0.0.1`, from a clean `GNUPGHOME` holding only the imported
> temporary `GNUPGHOME`, an ed25519 **signing subkey** was added to it, and the > public key — which is why the `[unknown]` trust marker and the warning are
> stand-in `SHA256SUMS.txt` was signed with `--local-user <subkey-fingerprint>!` > there rather than being written in by hand. The good-signature transcript is
> the same construction the release workflow uses — so the transcripts above > the real release's; the `BAD signature` transcript is the same command
> are what a subkey-signed release actually prints, rather than what a key > against a copy of `SHA256SUMS.txt` with one newline appended, and it exited
> signing with its primary would. The verification ran from a second > 1. The `sed`/`tr` pipeline printed
> `GNUPGHOME` holding only that key's public half, which is why the `[unknown]` > `A2061F6AB24DF2C0E92346FD1509B54946D08A95`, matching the fingerprint above.
> 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.
## 4. Check the hashes ## 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 the tarball can replace `SHA256SUMS.txt` next to it; the signature is the only
thing in the set they cannot forge. thing in the set they cannot forge.
> Verified on this host against the stand-in files: all three transcripts are > Verified against `v0.0.1`: with both tarballs present, `sha256sum -c` printed
> real `sha256sum` output over two random-byte files named like the release > three `OK` lines. The three transcripts above are the same command over
> tarballs plus an `IMAGE-DIGEST.txt` holding one image reference, with one > copies of the real assets, with the aarch64 tarball absent for the first two
> tarball deleted for the first two blocks and one byte appended to the other > and one byte appended to the x86_64 tarball for the third. Only the version
> for the third. Only the version in the filenames was replaced with > in the filenames was replaced with `<version>`.
> `<version>`.
## 5. Look inside before extracting ## 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 Zig version. The version has to match the tag you downloaded, and the commit
has to match the commit the tag points at. 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 ## 6. Verify the container image
@@ -343,23 +305,13 @@ docker rm nxdns-verify
sha256sum ./nxdns-from-image ./nxdns-$VERSION-x86_64-linux-musl/nxdns 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 > Verified against `v0.0.1`: the digest in `IMAGE-DIGEST.txt` and the digest
> against `git.mial.net/mokhtar/nxdns`. The two > the `:0.0.1` tag resolves to were the same string
> `docker buildx imagetools inspect --format` shapes were run on this host > (`sha256:f2945fbf6c1e16509f0e33e3d62da9a9cd7dc706718d333ce4edf95c80dbb00e`,
> against `alpine:3.22` on Docker Hub — the digest form printed > and `:latest` resolved to it too), the platform form printed exactly
> `sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce`, > `linux/amd64 linux/arm64` with no attestation entries, `docker pull` of the
> which is the digest this project's builder stage pins, and the platform form > pinned reference succeeded, and the binary copied out of that pulled image
> printed `linux/amd64 unknown/unknown linux/arm unknown/unknown ...`. That > hashed identical to the `nxdns` in the x86_64 tarball.
> `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.
## What the signature proves, and what it does not ## 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 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. `.gitea/workflows/gates.yml`, which is the workflow the release runs.
> Partly verified on this host. `zig build dist` and `sha256sum` on its output > Verified against `v0.0.1`, and the result is the caveat above in action. The
> were run to completion, with the version read out of `build.zig.zon`: `dist` > whole recipe ran from a fresh clone: `git verify-tag v0.0.1` printed
> exited 0 and wrote the two tarballs, `SHA256SUMS` and the staged payloads > `Good signature` under the same signing subkey as the release, and
> described above. `zig build verify-dist` was run on the result too and exited > `zig build dist` produced both tarballs. The hashes did **not** match the
> 0. What could not be run is everything that needs a release: the clone, the > published `SHA256SUMS.txt` — the binaries themselves already differ. The Zig
> checkout and `git verify-tag` need a tag that does not exist, and there is no > version matched the pin exactly; the Node version did not (24.14.1 against
> published `SHA256SUMS.txt` to compare a local build against, so the comparison > the pinned 24.19.0) and the build path differed, two of the ordinary causes
> this section is about has never been performed. > 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 ## If a check fails
+137 -59
View File
@@ -31,6 +31,9 @@ below carries all 56 of its entries.
- Mutations to groups, blocklists, rules, local records, forward zones, clients - Mutations to groups, blocklists, rules, local records, forward zones, clients
and client prefixes take effect live. Upstreams and `/api/settings` are and client prefixes take effect live. Upstreams and `/api/settings` are
restart-required. 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 ## 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 holds at most 32 concurrent streams in total; when all slots are taken, the
answer is a 503. 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 ## Operations
Auth `open` means no session is required; `session` means a valid session cookie 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; 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 | | Method | Path | Auth | Rate limit | Policy | Purpose |
|---|---|---|---|---| |---|---|---|---|---|---|
| GET | `/metrics` | open | exempt | Prometheus metrics | | GET | `/metrics` | open | exempt | read | Prometheus metrics |
| GET | `/api/health` | open | exempt | Health rollup | | GET | `/api/health` | open | exempt | read | Health rollup |
| GET | `/api/version` | open | counted | Build and uptime | | GET | `/api/version` | open | counted | read | Build and uptime |
| GET | `/api/openapi.yaml` | open | counted | This API's OpenAPI document | | GET | `/api/openapi.yaml` | open | counted | read | This API's OpenAPI document |
| POST | `/api/auth/login` | open | counted | Log in | | POST | `/api/auth/login` | open | counted | runtime action | Log in |
| POST | `/api/auth/logout` | session | counted | Log out | | POST | `/api/auth/logout` | session | counted | runtime action | Log out |
| GET | `/api/queries` | session | counted | Query log page | | GET | `/api/queries` | session | counted | read | Query log page |
| GET | `/api/queries/live` | session | exempt | Live query stream (server-sent events) | | GET | `/api/queries/live` | session | exempt | read | Live query stream (server-sent events) |
| GET | `/api/stats` | session | counted | Totals for a period | | GET | `/api/stats` | session | counted | read | Totals for a period |
| GET | `/api/stats/timeseries` | session | counted | Bucketed counts for a period | | GET | `/api/stats/timeseries` | session | counted | read | Bucketed counts for a period |
| GET | `/api/lookup` | session | counted | Explain a domain | | GET | `/api/lookup` | session | counted | read | Explain a domain |
| GET | `/api/upstream/health` | session | counted | Upstream pool health | | GET | `/api/upstream/health` | session | counted | read | Upstream pool health |
| GET | `/api/groups` | session | counted | List groups | | GET | `/api/groups` | session | counted | read | List groups |
| POST | `/api/groups` | session | counted | Create a group | | POST | `/api/groups` | session | counted | config write | Create a group |
| GET | `/api/groups/{id}` | session | counted | Read a group | | GET | `/api/groups/{id}` | session | counted | read | Read a group |
| PUT | `/api/groups/{id}` | session | counted | Update a group | | PUT | `/api/groups/{id}` | session | counted | config write | Update a group |
| DELETE | `/api/groups/{id}` | session | counted | Delete a group | | DELETE | `/api/groups/{id}` | session | counted | config write | Delete a group |
| GET | `/api/groups/{id}/sources` | session | counted | Blocklist sources assigned to a group | | GET | `/api/groups/{id}/sources` | session | counted | read | Blocklist sources assigned to a group |
| PUT | `/api/groups/{id}/sources` | session | counted | Replace the assignment | | PUT | `/api/groups/{id}/sources` | session | counted | config write | Replace the assignment |
| GET | `/api/blocklists` | session | counted | List blocklist sources | | GET | `/api/blocklists` | session | counted | read | List blocklist sources |
| POST | `/api/blocklists` | session | counted | Add a blocklist source | | POST | `/api/blocklists` | session | counted | config write | Add a blocklist source |
| POST | `/api/blocklists/update` | session | counted | Refresh every enabled source now | | POST | `/api/blocklists/update` | session | counted | runtime action | Refresh every enabled source now |
| GET | `/api/blocklists/{id}` | session | counted | Read a blocklist source | | GET | `/api/blocklists/{id}` | session | counted | read | Read a blocklist source |
| PUT | `/api/blocklists/{id}` | session | counted | Update a blocklist source | | PUT | `/api/blocklists/{id}` | session | counted | config write | Update a blocklist source |
| DELETE | `/api/blocklists/{id}` | session | counted | Delete a blocklist source | | DELETE | `/api/blocklists/{id}` | session | counted | config write | Delete a blocklist source |
| GET | `/api/rules` | session | counted | List rules | | GET | `/api/rules` | session | counted | read | List rules |
| POST | `/api/rules` | session | counted | Create a rule | | POST | `/api/rules` | session | counted | config write | Create a rule |
| GET | `/api/rules/{id}` | session | counted | Read a rule | | GET | `/api/rules/{id}` | session | counted | read | Read a rule |
| PUT | `/api/rules/{id}` | session | counted | Update a rule | | PUT | `/api/rules/{id}` | session | counted | config write | Update a rule |
| DELETE | `/api/rules/{id}` | session | counted | Delete a rule | | DELETE | `/api/rules/{id}` | session | counted | config write | Delete a rule |
| GET | `/api/local-records` | session | counted | List local DNS records | | GET | `/api/local-records` | session | counted | read | List local DNS records |
| POST | `/api/local-records` | session | counted | Create a local record | | POST | `/api/local-records` | session | counted | config write | Create a local record |
| GET | `/api/local-records/{id}` | session | counted | Read a local record | | GET | `/api/local-records/{id}` | session | counted | read | Read a local record |
| PUT | `/api/local-records/{id}` | session | counted | Update a local record | | PUT | `/api/local-records/{id}` | session | counted | config write | Update a local record |
| DELETE | `/api/local-records/{id}` | session | counted | Delete a local record | | DELETE | `/api/local-records/{id}` | session | counted | config write | Delete a local record |
| GET | `/api/forward-zones` | session | counted | List forward zones | | GET | `/api/forward-zones` | session | counted | read | List forward zones |
| POST | `/api/forward-zones` | session | counted | Create a forward zone | | POST | `/api/forward-zones` | session | counted | config write | Create a forward zone |
| GET | `/api/forward-zones/{id}` | session | counted | Read a forward zone | | GET | `/api/forward-zones/{id}` | session | counted | read | Read a forward zone |
| PUT | `/api/forward-zones/{id}` | session | counted | Update a forward zone | | PUT | `/api/forward-zones/{id}` | session | counted | config write | Update a forward zone |
| DELETE | `/api/forward-zones/{id}` | session | counted | Delete a forward zone | | DELETE | `/api/forward-zones/{id}` | session | counted | config write | Delete a forward zone |
| GET | `/api/clients` | session | counted | List clients | | GET | `/api/clients` | session | counted | read | List clients |
| GET | `/api/clients/{id}` | session | counted | Read a client | | GET | `/api/clients/{id}` | session | counted | read | Read a client |
| PUT | `/api/clients/{id}` | session | counted | Rename or regroup a client | | PUT | `/api/clients/{id}` | session | counted | config write | Rename or regroup a client |
| DELETE | `/api/clients/{id}` | session | counted | Forget a client | | DELETE | `/api/clients/{id}` | session | counted | runtime action | Forget a client |
| GET | `/api/client-prefixes` | session | counted | List client prefixes | | GET | `/api/client-prefixes` | session | counted | read | List client prefixes |
| PUT | `/api/client-prefixes` | session | counted | Replace the prefix table | | PUT | `/api/client-prefixes` | session | counted | config write | Replace the prefix table |
| GET | `/api/upstreams` | session | counted | List upstream resolvers | | GET | `/api/upstreams` | session | counted | read | List upstream resolvers |
| POST | `/api/upstreams` | session | counted | Add an upstream | | POST | `/api/upstreams` | session | counted | config write | Add an upstream |
| GET | `/api/upstreams/{id}` | session | counted | Read an upstream | | GET | `/api/upstreams/{id}` | session | counted | read | Read an upstream |
| PUT | `/api/upstreams/{id}` | session | counted | Update an upstream | | PUT | `/api/upstreams/{id}` | session | counted | config write | Update an upstream |
| DELETE | `/api/upstreams/{id}` | session | counted | Delete an upstream | | DELETE | `/api/upstreams/{id}` | session | counted | config write | Delete an upstream |
| GET | `/api/pause` | session | counted | Read the pause state | | GET | `/api/pause` | session | counted | read | Read the pause state |
| POST | `/api/pause` | session | counted | Pause or resume blocking | | POST | `/api/pause` | session | counted | runtime action | Pause or resume blocking |
| GET | `/api/settings` | session | counted | Read the scalar settings | | GET | `/api/settings` | session | counted | read | Read the scalar settings |
| PUT | `/api/settings` | session | counted | Update settings | | PUT | `/api/settings` | session | counted | config write | Update settings |
| POST | `/api/certs/reload` | session | counted | Reload the TLS certificates from disk | | 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, There is no `POST /api/clients`: client rows come from DNS activity or import,
never from the API. 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 `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. 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 ## Schemas
Request and response schemas for every operation live in the OpenAPI document: Request and response schemas for every operation live in the OpenAPI document:
+212 -48
View File
@@ -9,9 +9,10 @@ of truth: `src/cli.zig`.
Every flag takes both spellings, `--flag value` and `--flag=value`. An attached 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. 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 `--allow-delete` is boolean and takes no value at all, so `--allow-delete=1` is
of any flag this program has. A flag is rejected by the subcommand that has no not a spelling of any flag this program has. A flag is rejected by the
use for it: `--web-dev` outside `run` is an unknown flag, not a no-op. subcommand that has no use for it: `--web-dev` outside `run` is an unknown flag,
not a no-op.
## `run` ## `run`
@@ -20,12 +21,81 @@ Serves DNS until SIGINT or SIGTERM.
| Flag | Meaning | | Flag | Meaning |
| --- | --- | | --- | --- |
| `--data-dir DIR` | Data directory (default `/var/lib/nxdns`). Created at mode 0700 if missing. | | `--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. | | `--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 ### Which authority the invocation selects
exits 2 — the same code `check` and `import` give for the same file. See
[exit codes](#exit-codes). 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` ## `check`
@@ -52,7 +122,7 @@ first. What it checks, in order:
| Flag | Meaning | | Flag | Meaning |
| --- | --- | | --- | --- |
| `--data-dir DIR` | Data directory to look for `config.db` in. | | `--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 ### Failures and warnings
@@ -71,23 +141,38 @@ The last line is a summary, and it never contradicts the lines above it:
### Source selection ### Source selection
- `--config FILE` given explicitly: check that file, nothing else. The flag decides, exactly as it does for `run`. There is no fallback and no
- Otherwise, if `<data-dir>/config.db` exists: check the database — the right probing of a default path:
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 first line of output always names which source was checked. A file larger - `--config FILE`: grade that file, and never open the database.
than 4 MiB fails with `larger than 4194304 bytes`; a ZON syntax error is - 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. reported with its line and column.
A named file that is missing or unreadable is a finding like any other, not an 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 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 `FAIL <path>: not readable`, exit 2.
gives when there is nothing to check, so naming the file does not change what a
missing file costs. 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 ### 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 database is opened with create semantics, so an empty `config.db` is created and
migrated, and the export is of a default configuration. 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 | | Flag | Meaning |
| --- | --- | | --- | --- |
| `--data-dir DIR` | Data directory holding `config.db`. | | `--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` ## `import FILE`
Validates FILE and replaces the whole configuration with it in one transaction. Converges the database onto FILE in one transaction — the same reconcile a
Prints every validation problem; a failed import leaves the database untouched. file-mode `run` performs, done once from the command line. Prints every
Refuses a database that already holds configuration unless `--force` is given validation problem; a failed import leaves the database untouched. Creates the
(`DatabaseNotEmpty`, exit 2). The check measures what an operator set, not what data directory at mode 0700 if it is missing.
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.
`FILE` is positional and may appear before or after the flags. `FILE` is positional and may appear before or after the flags.
| Flag | Meaning | | Flag | Meaning |
| --- | --- | | --- | --- |
| `--data-dir DIR` | Data directory holding `config.db` (created if missing). | | `--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` ## `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 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 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 2 whichever command read it. The list is every error the validator raises, plus
`ParseZon`, `ConfigTooLarge`, `NoUsableUpstreams` and `BadCertificate`. In `ParseZon`, `ConfigTooLarge`, `NoUsableUpstreams`, `BadCertificate` and
practice that covers a seed file with a syntax error, one larger than 4 MiB, one `ManagedConfigUnreadable`. In practice that covers a file with a syntax error,
with no `default` group (`MissingDefaultGroup`), one with no enabled upstream one larger than 4 MiB, one with no `default` group (`MissingDefaultGroup`), one
(`NoUpstreams`), a bad bind address, a bad rate limit, an unusable certificate, with no enabled upstream (`NoUpstreams`), a bad bind address, a bad rate limit,
and `password` and `password_hash` set together. 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 The last of those is the one deliberate seam. A file nxdns cannot open is a
from the seed file or from the database it loaded: 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 run `nxdns check` to see the configuration in full
``` ```
`check` exits 2 for those faults and also when a probed upstream failed, when a On a box with no configuration at all, `run` and `check` add the line that names
named configuration file is missing or unreadable, when the database cannot be both ways to get one:
read or is not at this binary's schema version, and when there was nothing to
check. Warnings never contribute.
`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 load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
rather than the content of a file — and `import` decides it for itself; the ```
answer to it is `--force`, not an edit.
`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 `OutOfMemory` is exit 1 even when problems were recorded, because the report is
then incomplete. Every other error is 1. then incomplete. Every other error is 1.
+50 -18
View File
@@ -4,7 +4,7 @@ Every section, field and collection nxdns accepts, with its type, default,
unit, validation rule and the subsystem that consumes it. unit, validation rule and the subsystem that consumes it.
Source of truth: `src/config/model.zig` (the model and the defaults), 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). (the lifecycle).
For how the file, the database and `export`/`import` relate to each other, see 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 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`, collections below. Enum values are ZON enum literals (`.level = .err`,
`.response = .nxdomain`). Strings are double-quoted. The file may be at most `.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. `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 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 | | Argument | Default | Meaning |
| --- | --- | --- | | --- | --- | --- |
| `--data-dir DIR` | `/var/lib/nxdns` | Holds `config.db` and `querylog.db`; see [files and directories](files-and-directories.md). | | `--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. | | `--web-dev DIR` | — | `run` only; serves the web interface from a directory instead of the embedded assets. |
## Scalar sections ## 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.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.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 | 165535 (0 is refused) | web listener port | | `web.port` | u16 | 8080 | port | 165535 (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` | 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` | string | `""` | — | — | argon2id PHC string verified at login (`src/web/auth.zig`); `""` disables authentication | | `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.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_rate_limit_per_min` | u32 | 300 | requests per minute | at least 1 | API token-bucket limiter (`src/web/api_limiter.zig`) |
| `web.api_localhost_exempt` | bool | true | — | — | loopback requests skip the API limiter | | `web.api_localhost_exempt` | bool | true | — | — | loopback requests skip the API limiter |
@@ -341,27 +341,59 @@ deadline.
## Password and hash ## Password and hash
Exactly one of `web.password` and `web.password_hash` may be set; setting both Both fields are optional, and the difference between *absent* and *empty* is the
is refused (`PasswordAndHashBothSet` — ambiguity in a security setting). 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 | The file says | What happens to the stored hash |
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, | Neither field | Untouched. Authentication stays exactly as it was. |
and `nxdns export` always writes `.password = ""`. | `.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, - `web.password_hash` is the stored argon2id PHC string. Supplying it directly,
for example from a previous export, is how a backup restores authentication for example from a previous export, is how a backup restores authentication
without knowing the password. without knowing the password.
- Both empty disables web authentication entirely.
Because the export carries the hash and re-importing an exported file takes the A plaintext password that has not changed is verified rather than re-hashed, so
"password is empty" branch, the export/import round trip preserves the hash applying the same file twice leaves the same bytes in the database. That is what
byte for byte. See [set up admin 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). authentication](../how-to/set-up-admin-authentication.md).
## Validation errors ## Validation errors
`nxdns check`, `nxdns import` and the `nxdns run` that seeds a database from the `nxdns check`, `nxdns import` and a `nxdns run --config` that reads the file
file all print one `FAIL path: message` line per problem, and report every 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 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 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 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 }, .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). // leave .password_hash out when setting .password (they are exclusive).
.web = .{ .web = .{
.enabled = true, .enabled = true,
+18 -9
View File
@@ -10,11 +10,9 @@ snapshots), `src/platform/logging.zig` (the log file).
Default `/var/lib/nxdns`, overridable with `--data-dir DIR`. `nxdns run` and 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 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 `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 there. `check` opens it only when no `--config FILE` was given: with that flag it
absent data directory is not in itself a failure: an explicit `--config FILE` grades the file and never looks at the directory at all. Without it, an absent
never looks at the directory, and without one `check` falls back to the default `config.db` is a failure naming the two ways to get one, exit 2.
configuration file, or prints "nothing to check" and exits 2 when neither source
exists.
`run`, `import` and `export` go through `DataDir.openConfigDb`, which opens `run`, `import` and `export` go through `DataDir.openConfigDb`, which opens
`config.db` read/write, chmods it to 0600, enables WAL — creating `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 | | 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 | | `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` | 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 | | `querylog.db-wal`, `querylog.db-shm` | WAL sidecars for `querylog.db`. | 0600 |
@@ -112,9 +110,20 @@ than being created world-readable.
## The configuration file ## The configuration file
Default `/etc/nxdns/config.zon`, overridable with `--config FILE`. nxdns reads There is no default path. `--config FILE` names the file, and without that flag
it and never writes it: it seeds an unconfigured database once and is ignored no file is read at all — a `config.zon` sitting in `/etc/nxdns` that no
afterwards. nxdns does not create the file or its directory. 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 `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 file and a rename. That file carries `web.password_hash`, so treat exports as
+98 -36
View File
@@ -9,8 +9,13 @@ delete the directory.
Follow the steps in order. Each one says what it did. 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 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 24.14.1, dig 9.20.26 and curl 8.21.0. Steps 4 to 11, 13 and 14 were re-run end
was the tutorial directory. 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 ## 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 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. find no admin interface there.
## 2. Build nxdns ## 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` 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. 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 ## 4. Check the configuration before starting
```sh ```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 otherwise. It writes nothing and starts no listener, so you can run it as often
as you like. as you like.
## 5. Start the server ## 5. Load it into the database
In your first terminal:
```sh ```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(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(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(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(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 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 No `--config` here, and that is the point: `authority: database` says the
`config.db` and `querylog.db`. The line that matters is `seeded the database database is the configuration and no file was opened at all. The file you wrote
from`: the configuration file was read into the database this once. From now on in step 3 has done its job.
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`.
Leave this terminal running and switch to the second one. Leave this terminal running and switch to the second one.
## 6. Resolve a name ## 7. Resolve a name
```sh ```sh
dig @127.0.0.1 -p 15353 example.com A +noall +answer 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 nxdns had no answer cached, so it forwarded the query to
`https://cloudflare-dns.com/dns-query` over HTTPS and returned what came back. `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`. said `0 of 0 sources loaded`.
## 7. Add a blocklist source ## 8. Add a blocklist source
```sh ```sh
curl -s -X POST http://127.0.0.1:8080/api/blocklists \ 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. 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 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 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]} {"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: 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 ```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 The download is about 3 MB and takes a few seconds. Watch the first terminal
until this appears: 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 `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 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 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`. From here on, the list is on disk under `~/nxdns-tutorial/data/blocklists`.
Restarting nxdns does not re-download it. Restarting nxdns does not re-download it.
## 10. Watch a domain get blocked ## 11. Watch a domain get blocked
```sh ```sh
dig @127.0.0.1 -p 15353 doubleclick.net A +noall +answer 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. pick a domain to test, pick one that is literally in the file.
`www.google-analytics.com` is another that is. `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 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 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 domain count. (The endpoints behind those two pages were checked while writing
this; the browser page itself was not opened on the verification host.) 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. 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. 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, 3096006 bytes
info(blocklist_manager): blocklist snapshot generation 1: 1 of 1 sources loaded, 3088703 bytes info(nxdns): authority: database
``` ```
The configuration file was not even opened, and the blocklist came off disk The blocklist came off disk rather than the network. The source you added, the
rather than the network. The blocklist source, the group it is attached to, and group it is attached to, and the query log all survived the restart because they
the query log all survived the restart because they live in live in `~/nxdns-tutorial/data`, which is what `authority: database` means in
`~/nxdns-tutorial/data`. practice.
Press Ctrl-C again to stop this second process. Nothing is listening on 15353 or Press Ctrl-C again to stop this second process. Nothing is listening on 15353 or
8080 now, and nothing of nxdns is running. 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 ## What you have now
A resolver that answers real queries, a real blocklist of about 99000 domains 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 rm -rf ~/nxdns-tutorial
``` ```
You also saw the two things that surprise people most: the configuration file You also saw the three things that surprise people most: a blocklist source
seeds the database once and is ignored afterwards, and a blocklist source does does nothing until a group uses it, which authority a start runs under is
nothing until a group uses it. printed rather than guessed, and in file mode anything the file does not name is
removed at the next start.
## Where to go next ## Where to go next
@@ -305,4 +367,4 @@ nothing until a group uses it.
- [reference/configuration.md](../reference/configuration.md) — every field you - [reference/configuration.md](../reference/configuration.md) — every field you
did not set. did not set.
- [explanation/configuration-model.md](../explanation/configuration-model.md) — - [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.
+24 -10
View File
@@ -695,13 +695,16 @@ was reproduced before it was fixed.
- [x] Two runs of `zig build dist` on the same commit produce byte-identical - [x] Two runs of `zig build dist` on the same commit produce byte-identical
tarballs **in the same directory**. (Cross-directory reproducibility is tarballs **in the same directory**. (Cross-directory reproducibility is
ruling 12 and is not claimed here.) 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 `/THIRD-PARTY-NOTICES` and the OCI labels, and its binaries are
byte-identical to the tarball binaries. Verified for the native amd64 byte-identical to the tarball binaries. The v0.0.1 run built and pushed
image only; the arm64 half needs a runner with buildx. both platforms on the runner; the published index lists exactly
- [ ] `gates.yml` runs from both `ci.yml` and `release.yml`; `ci.yml` triggers `linux/amd64 linux/arm64`, and `release verify-image-binaries` compared
on `master`; `origin/main` is gone. The first two are in the files; no both binaries against the tarballs before publication.
workflow has run and `origin/main` still exists (manual prerequisite). - [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 - [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 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: runtime closure. The dependency drift guard was proven able to fail:
@@ -715,10 +718,21 @@ was reproduced before it was fixed.
multi-architecture image — and was then deleted (release, git tag, both multi-architecture image — and was then deleted (release, git tag, both
registry versions). Five attempts; the failures and their fixes are registry versions). Five attempts; the failures and their fixes are
deviations 23-26. deviations 23-26.
- [ ] `v0.0.1` is published: five assets, a verifying signature, and an image at - [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`. `git.mial.net/mokhtar/nxdns:0.0.1` and `:latest`. Run 493, all jobs green
- [ ] `docs/how-to/verify-a-release.md` was followed end to end against the on the first attempt after the dry-run fixes.
published release, from a clean directory, on this host. - [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 - [x] No `zig build cross` or source-only-distribution text remains on any
**active** surface: `build.zig`, the workflows, `deploy/`, `README.md` and **active** surface: `build.zig`, the workflows, `deploy/`, `README.md` and
`docs/`. Historical milestone specs and `TECH_DEBT.md` keep their text — `docs/`. Historical milestone specs and `TECH_DEBT.md` keep their text —
+921
View File
@@ -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`.
+342
View File
@@ -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
View File
@@ -32,7 +32,6 @@ const tls = std.crypto.tls;
const api_limiter = @import("web/api_limiter.zig"); const api_limiter = @import("web/api_limiter.zig");
const auth = @import("web/auth.zig"); const auth = @import("web/auth.zig");
const bootstrap = @import("config/bootstrap.zig");
const cert_store = @import("server/cert_store.zig"); const cert_store = @import("server/cert_store.zig");
const cli = @import("cli.zig"); const cli = @import("cli.zig");
const clients = @import("server/clients.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 forward_zones = @import("local/forward_zones.zig");
const handler = @import("server/handler.zig"); const handler = @import("server/handler.zig");
const http_util = @import("web/http_util.zig"); const http_util = @import("web/http_util.zig");
const loader = @import("config/loader.zig");
const local_records = @import("local/records.zig"); const local_records = @import("local/records.zig");
const local_tables = @import("server/local_tables.zig"); const local_tables = @import("server/local_tables.zig");
const logger_mod = @import("storage/logger.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 pool_mod = @import("upstream/pool.zig");
const query_sink = @import("server/query_sink.zig"); const query_sink = @import("server/query_sink.zig");
const rate_limiter = @import("server/rate_limiter.zig"); const rate_limiter = @import("server/rate_limiter.zig");
const reconcile = @import("config/reconcile.zig");
const retention_mod = @import("storage/retention.zig"); const retention_mod = @import("storage/retention.zig");
const safe_url = @import("safe_url.zig"); const safe_url = @import("safe_url.zig");
const shutdown = @import("server/shutdown.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); const mapped = failureExitCode(err);
if (mapped == cli.exit_check) { if (mapped == cli.exit_check) {
runner.err.writeAll("run `nxdns check` to see the configuration in full\n") catch {}; 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; break :code mapped;
}; };
@@ -113,70 +120,201 @@ fn failureExitCode(err: anyerror) u8 {
return if (faults.isConfigFault(err)) cli.exit_check else cli.exit_runtime; 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 /// File authority (ruling 2): read the file the operator named, validate it, and
/// after. Its diagnostics are the operator's one chance to see what the file /// converge the database onto it — on this start and on every start after it.
/// said, so they are printed the way `check` and `import` print them. /// 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 /// A missing, unreadable or invalid file fails the start. There is no fallback
/// carry warnings — a blocklist source in no group is downloaded and compiled /// to the database under any failure: a fallback turns a deploy typo into a
/// into nothing — and a warning that only appears when the start fails is a /// silently stale configuration, which is the failure mode the whole mode exists
/// warning nobody ever reads: the start it describes is the one that worked. /// to prevent.
/// `check` reported it and `run` did not, which left the same file graded two
/// ways.
/// ///
/// The runner's error writer, not `std.log`: this runs before /// Diagnostics are printed on the way out either way. A file can be accepted and
/// `logging.install`, and one rendering of a diagnostic across `run`, `check` /// still carry warnings — a blocklist source in no group is downloaded and
/// and `import` is the point of `Diagnostics.writeAll`. /// 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 /// The runner's writers, not `std.log`: this runs before `logging.install`, and
/// (`main` gives it 4 KiB) and `serve` does not return for as long as the /// one rendering of a diagnostic across `run`, `check` and `import` is the point
/// service runs, so a line left in the buffer reaches the operator when the /// 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 /// process stops — days after the start it describes. A failure path flushes
/// anyway because it returns immediately; the successful start is the one that /// anyway because it returns immediately; the successful start is the one that
/// needs this. /// needs this.
fn seedFromFile( fn reconcileFromFile(
r: cli.Runner, r: cli.Runner,
config_db: *db.Db, config_db: *db.Db,
dir: std.Io.Dir, dir: std.Io.Dir,
config_path: []const u8, 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); var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit(); 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. // Both discards are deliberate, and they answer different questions. On a
// // rejected file the operator gets the reason the start failed, never a
// On a rejected seed file, `result` is returned untouched: the operator gets // writer error standing in front of it — a broken stderr is not why the
// the reason the start failed, never a writer error standing in front of it. // configuration was refused. On a file that applied, a failure here does not
// A broken stderr is not why the configuration was refused. // 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
// On a seed that worked, a failure here does not stop the start. The trade // failure has nowhere to be reported anyway.
// 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.
diags.writeAll(r.err) catch {}; diags.writeAll(r.err) catch {};
r.err.flush() catch {}; r.err.flush() catch {};
return result; 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 { fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
const io = r.io; const io = r.io;
const gpa = r.gpa; const gpa = r.gpa;
@@ -193,7 +331,15 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
defer config_db.close(); defer config_db.close();
_ = try migrations.migrate(&config_db); _ = 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, // 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 // 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(); defer arena_state.deinit();
const arena = arena_state.allocator(); 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); const cfg = try config_export.readConfig(&config_db, arena);
// From here on `std.log` goes wherever the operator asked. Before this call // 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 = .{ if (cfg.web.enabled) web_state = .{
.gpa = gpa, .gpa = gpa,
.web = cfg.web, .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, .handler = &h,
.pause = &paused, .pause = &paused,
.tracker = &tracker, .tracker = &tracker,
@@ -602,7 +761,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// this box exists for — keeps serving. // this box exists for — keeps serving.
if (cfg.web.enabled) try group.concurrent(io, web_server.serve, .{ &web_state, io }); 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, .udp6 = if (udp6) |*s| s.boundAddress() else null,
.udp4 = if (udp4) |*s| s.boundAddress() else null, .udp4 = if (udp4) |*s| s.boundAddress() else null,
.tcp6 = if (tcp6) |*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 // `run --config`: `serve` validates through this exact call before it
// exact call, so this is the error `run` classifies. // reconciles, so this is the error `run` classifies.
var database = try db.Db.open(":memory:", .{ .mode = .memory }); var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close(); defer database.close();
try db.applyPragmas(&database, .{}); 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()); 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" { test "a start in file mode prints the warnings the file earned" {
// D5, second half. The seed file is read once in the life of a database, so // D5, second half. `run` printed diagnostics only when the file was
// a warning it earns is printed on that start or never. `run` printed // rejected, which made a successful start the one place a finding could not
// diagnostics only when the file was rejected, which made a successful first // surface. Under file authority the file is read on every start, so this is
// start the one place the finding could not surface. // the line an operator sees after every restart, not only the first.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit(); defer threaded.deinit();
const io = threaded.io(); 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); var err_writer = err_file.writer(io, &err_buf);
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface }; 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. // The file is valid, so the start succeeds and the database converges onto
try std.testing.expectEqual( // it. The returned stamp is what the settings envelope reports.
bootstrap.Outcome.seeded, const reconciled_at = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
try seedFromFile(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")); 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 // 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")); 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 /// 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 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()` /// 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; return w;
} }
test "a broken error writer does not replace the reason a seed file was rejected" { test "a broken error writer does not replace the reason a managed file was rejected" {
// The operator has to see why seeding failed, and a broken stderr is not // The operator has to see why the start failed, and a broken stderr is not
// that reason. // that reason.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit(); 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( try std.testing.expectError(
error.MissingDefaultGroup, 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 // 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); 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 // 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 // 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. // 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); var err_writer = brokenErrWriter(io, err_file, &err_buf);
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface }; const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface };
try std.testing.expectEqual( _ = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
bootstrap.Outcome.seeded,
try seedFromFile(r, &database, tmp.dir, "config.zon"),
);
try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams")); try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
// Nothing reached the file, so the flush really did fail. // 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` /// 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 /// right after a restart: where it listens, how many upstreams it has, and
/// whether filtering is live. /// 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 buf: [256]u8 = undefined;
var w: Writer = .fixed(&buf); var w: Writer = .fixed(&buf);
appendBind(&w, "udp", bound.udp6); 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, /// Silent on overflow: a truncated startup line is not worth a failure path,
/// and 256 bytes hold four addresses. /// and 256 bytes hold four addresses.
fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void { 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 {}; 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"); const test_address = @import("platform/address.zig");
test "one maintenance pass drops the api limiter's stale buckets" { test "one maintenance pass drops the api limiter's stale buckets" {
+165 -106
View File
@@ -22,6 +22,7 @@ const app = @import("app.zig");
const config_export = @import("config/export.zig"); const config_export = @import("config/export.zig");
const faults = @import("config/faults.zig"); const faults = @import("config/faults.zig");
const import = @import("config/import.zig"); const import = @import("config/import.zig");
const loader = @import("config/loader.zig");
const model = @import("config/model.zig"); const model = @import("config/model.zig");
const validate = @import("config/validate.zig"); const validate = @import("config/validate.zig");
const cert_store = @import("server/cert_store.zig"); const cert_store = @import("server/cert_store.zig");
@@ -50,19 +51,31 @@ pub const querylog_db_name = "querylog.db";
pub const Paths = struct { pub const Paths = struct {
/// PLAN §3.13. /// PLAN §3.13.
data_dir: []const u8 = "/var/lib/nxdns", data_dir: []const u8 = "/var/lib/nxdns",
config: []const u8 = "/etc/nxdns/config.zon",
}; };
/// `config_explicit` records whether `--config` was given, because `check` has /// Milestone-20 ruling 1: `--config` has no default path, and its presence is
/// to tell "the operator named a file" from "the default path happens to /// the whole of the authority decision. Null means the database is authority —
/// exist". /// for `run` that is today's appliance behaviour, for `check` it is the database
pub const CheckArgs = struct { paths: Paths = .{}, config_explicit: bool = false }; /// 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 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 /// `web_dev` is milestone-8 ruling 24's `--web-dev <dir>`: serve the web
/// interface from that directory instead of the embedded assets. /// 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) { pub const Command = union(enum) {
run: RunArgs, run: RunArgs,
@@ -176,7 +189,7 @@ fn parseRunArgs(argv: []const []const u8) ParseError!RunArgs {
if (eql(flag.name, "data-dir")) { if (eql(flag.name, "data-dir")) {
args.paths.data_dir = try flagValue(flag, argv, &i); args.paths.data_dir = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "config")) { } 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")) { } else if (eql(flag.name, "web-dev")) {
args.web_dev = try flagValue(flag, argv, &i); args.web_dev = try flagValue(flag, argv, &i);
} else return error.UnknownFlag; } else return error.UnknownFlag;
@@ -192,8 +205,7 @@ fn parseCheckArgs(argv: []const []const u8) ParseError!CheckArgs {
if (eql(flag.name, "data-dir")) { if (eql(flag.name, "data-dir")) {
args.paths.data_dir = try flagValue(flag, argv, &i); args.paths.data_dir = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "config")) { } else if (eql(flag.name, "config")) {
args.paths.config = try flagValue(flag, argv, &i); args.config = try flagValue(flag, argv, &i);
args.config_explicit = true;
} else return error.UnknownFlag; } else return error.UnknownFlag;
} }
return args; return args;
@@ -215,7 +227,7 @@ fn parseExportArgs(argv: []const []const u8) ParseError!ExportArgs {
fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs { fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs {
var paths: Paths = .{}; var paths: Paths = .{};
var force = false; var allow_delete = false;
var file: ?[]const u8 = null; var file: ?[]const u8 = null;
var i: usize = 0; var i: usize = 0;
@@ -227,18 +239,18 @@ fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs {
}; };
if (eql(flag.name, "data-dir")) { if (eql(flag.name, "data-dir")) {
paths.data_dir = try flagValue(flag, argv, &i); paths.data_dir = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "force")) { } else if (eql(flag.name, "allow-delete")) {
// A boolean flag takes no value, so `--force=1` is not a spelling of // A boolean flag takes no value, so `--allow-delete=1` is not a
// any flag this program has. // spelling of any flag this program has.
if (flag.attached != null) return error.UnknownFlag; if (flag.attached != null) return error.UnknownFlag;
force = true; allow_delete = true;
} else return error.UnknownFlag; } else return error.UnknownFlag;
} }
return .{ return .{
.paths = paths, .paths = paths,
.file = file orelse return error.MissingArgument, .file = file orelse return error.MissingArgument,
.force = force, .allow_delete = allow_delete,
}; };
} }
@@ -379,14 +391,30 @@ const usage_text =
\\ \\
\\options: \\options:
\\ --data-dir DIR data directory (default /var/lib/nxdns) \\ --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 \\ --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 \\ --web-dev DIR run only: serve the web interface from DIR instead of
\\ the embedded assets \\ 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 /// Returns nothing, so a writer failure here has nowhere to go. Every caller
/// flushes afterwards and reports that failure instead. /// flushes afterwards and reports that failure instead.
pub fn usage(w: *Writer) void { pub fn usage(w: *Writer) void {
@@ -493,7 +521,7 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
&database, &database,
std.Io.Dir.cwd(), std.Io.Dir.cwd(),
args.file, args.file,
.{ .force = args.force }, .{ .allow_delete = args.allow_delete },
diags, 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 /// 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). /// what stops `run`, `check` and `import` drifting apart again (D1).
/// ///
/// `error.DatabaseNotEmpty` is the one exception, and it is deliberate: it /// `error.DestructiveImport` is the one exception, and it is deliberate: it
/// reports the state of the database rather than the content of a file, so it /// reports what the diff would do to the database rather than the content of a
/// is not a configuration fault, and `import` alone decides it is exit 2. /// 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. /// `error.OutOfMemory` is matched first, before anything else is consulted.
/// Both recording paths — `validate` and import's per-line rendering of a ZON /// 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 { fn failureExitCode(e: anyerror, failures: usize) u8 {
if (e == error.OutOfMemory) return exit_runtime; if (e == error.OutOfMemory) return exit_runtime;
if (failures != 0) return exit_check; 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; return if (faults.isConfigFault(e)) exit_check else exit_runtime;
} }
@@ -563,30 +592,33 @@ fn checkImpl(r: Runner, args: CheckArgs, probe: bool) !u8 {
// Which source was used is printed in every branch, so the answer is never // Which source was used is printed in every branch, so the answer is never
// ambiguous about what it checked. // ambiguous about what it checked.
if (args.config_explicit) { //
try r.out.print("checking configuration file {s}\n", .{args.paths.config}); // Ruling 1: the invocation decides, and nothing else. The heuristic that
return checkFile(r, arena, args.paths.config, probe); // 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 }); 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)) { 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;
}
try r.out.print("checking database {s}\n", .{config_db_path}); try r.out.print("checking database {s}\n", .{config_db_path});
return checkDatabase(r, arena, config_db_path, probe); return checkDatabase(r, arena, config_db_path, probe);
} }
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;
}
/// `check` reads `config.db` and writes nothing to it (F-c): no create, no /// `check` reads `config.db` and writes nothing to it (F-c): no create, no
/// chmod, no `applyPragmas` — which is what would turn WAL on and leave /// chmod, no `applyPragmas` — which is what would turn WAL on and leave
/// `config.db-wal` and `config.db-shm` behind — and above all no `migrate`. A /// `config.db-wal` and `config.db-shm` behind — and above all no `migrate`. A
@@ -722,52 +754,31 @@ fn pathReadable(io: std.Io, path: []const u8) std.Io.Dir.AccessError!bool {
return true; 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 { 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,
};
// 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) {
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); var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit(); defer diags.deinit();
try import.reportParseFailure(&diags, &zon_diag);
const cfg = loader.load(r.io, arena, std.Io.Dir.cwd(), path, &diags) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
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); try diags.writeAll(r.out);
return exit_check; 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); return checkConfig(r, cfg, probe);
@@ -1013,17 +1024,22 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
const testing = std.testing; 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"}); const command = try parseArgs(&.{"run"});
try testing.expectEqualStrings("/var/lib/nxdns", command.run.paths.data_dir); 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); 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" }); 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("/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" { 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); 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"}); 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" }); const explicit = try parseArgs(&.{ "check", "--config=/tmp/c.zon" });
try testing.expect(explicit.check.config_explicit); try testing.expectEqualStrings("/tmp/c.zon", explicit.check.config.?);
try testing.expectEqualStrings("/tmp/c.zon", explicit.check.paths.config);
} }
test "parseArgs accepts export with --out" { 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); try testing.expectEqual(@as(?[]const u8, null), bare.export_.out);
} }
test "parseArgs accepts import with a file, --force and --data-dir" { test "parseArgs accepts import with a file, --allow-delete and --data-dir" {
const command = try parseArgs(&.{ "import", "c.zon", "--force", "--data-dir=/srv/nx" }); const command = try parseArgs(&.{ "import", "c.zon", "--allow-delete", "--data-dir=/srv/nx" });
try testing.expectEqualStrings("c.zon", command.import_.file); 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); try testing.expectEqualStrings("/srv/nx", command.import_.paths.data_dir);
} }
test "parseArgs accepts import with the file after the flags" { test "parseArgs accepts import with the file after the flags" {
const command = try parseArgs(&.{ "import", "--data-dir", "/srv/nx", "c.zon" }); const command = try parseArgs(&.{ "import", "--data-dir", "/srv/nx", "c.zon" });
try testing.expectEqualStrings("c.zon", command.import_.file); 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" { test "parseArgs accepts version" {
@@ -1091,7 +1110,7 @@ test "parseArgs accepts help, --help and -h" {
test "parseArgs rejects import without a file" { test "parseArgs rejects import without a file" {
try testing.expectError(error.MissingArgument, parseArgs(&.{"import"})); 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" { test "parseArgs rejects --out without a value" {
@@ -1101,7 +1120,7 @@ test "parseArgs rejects --out without a value" {
test "parseArgs rejects an unknown flag" { test "parseArgs rejects an unknown flag" {
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "check", "--nope" })); 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" { 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" { test "usage writes non-empty text" {
var out: Writer.Allocating = .init(testing.allocator); var out: Writer.Allocating = .init(testing.allocator);
defer out.deinit(); 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" { 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.ParseZon, 0));
try testing.expectEqual(exit_check, failureExitCode(error.NoUpstreams, 1)); try testing.expectEqual(exit_check, failureExitCode(error.NoUpstreams, 1));
try testing.expectEqual(exit_runtime, failureExitCode(error.IoErr, 0)); 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 one config-shaped exit 2 `cli` still decides for itself: it reports
// the state of the database, not the content of a file. // what the diff would do to the database, not the content of a file.
try testing.expect(!faults.isConfigFault(error.DatabaseNotEmpty)); try testing.expect(!faults.isConfigFault(error.DestructiveImport));
try testing.expectEqual(exit_check, failureExitCode(error.DatabaseNotEmpty, 0)); 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"); 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(); defer captured.deinit();
const r = captured.runner(); const r = captured.runner();
const code = runCheck(r, .{ const code = runCheck(r, .{ .config = env.missing_path }, false);
.paths = .{ .config = env.missing_path },
.config_explicit = true,
}, false);
try testing.expectEqual(exit_check, code); try testing.expectEqual(exit_check, code);
const text = captured.out.written(); 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()); 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" { 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 // 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 // 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; var path_buf: [160]u8 = undefined;
const config_path = try env.path(&path_buf, "config.zon"); const config_path = try env.path(&path_buf, "config.zon");
const code = runCheck(r, .{ const code = runCheck(r, .{ .config = config_path }, false);
.paths = .{ .config = config_path },
.config_explicit = true,
}, false);
try testing.expectEqual(exit_check, code); try testing.expectEqual(exit_check, code);
const text = captured.out.written(); const text = captured.out.written();
-141
View File
@@ -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
View File
@@ -34,7 +34,7 @@ pub const Error = ReadError || Writer.Error ||
const header = const header =
\\// nxdns configuration \\// 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.local_records = (try local_repo.listLocalRecords(database, arena)).items;
cfg.forward_zones = (try local_repo.listForwardZones(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 // `web.password` is operator input and is never stored, so the exported
// always carries an empty one. This is exactly what makes the round trip // file always states it as absent. Absent rather than `""`: a present empty
// stable: re-importing takes the "password is empty" branch and stores the // password is refused by `validate` (ruling 4), so exporting one would make
// same hash. // every export fail its own rules. It is also what makes the round trip
cfg.web.password = ""; // stable — re-applying the file takes the "password_hash written verbatim"
// branch and stores the same hash.
cfg.web.password = null;
return cfg; 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.dns.port, b.dns.port);
try testing.expectEqual(a.logging.level, b.logging.level); 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.groups.len, b.groups.len);
try testing.expectEqual(a.upstreams.len, b.upstreams.len); try testing.expectEqual(a.upstreams.len, b.upstreams.len);
for (a.upstreams, b.upstreams) |left, right| { 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" }}, .upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
.web = .{ .password = "correct horse battery staple" }, .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); var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit(); defer arena_state.deinit();
const exported = try readConfig(&database, arena_state.allocator()); const exported = try readConfig(&database, arena_state.allocator());
try testing.expectEqualStrings("", exported.web.password); try testing.expectEqual(@as(?[]const u8, null), exported.web.password);
try testing.expect(std.mem.startsWith(u8, exported.web.password_hash, "$argon2id$")); 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
View File
@@ -13,17 +13,26 @@ const validate = @import("validate.zig");
/// `ValidateError` enters as a whole set rather than variant by variant, so a /// `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 /// variant added to the validator cannot silently fall through to exit 1. The
/// four extras are the configuration faults raised outside the validator: the /// five extras are the configuration faults raised outside the validator: the
/// ZON reader (`ParseZon`), the seed-file size limit (`ConfigTooLarge`), the /// ZON reader (`ParseZon`), the file size limit (`ConfigTooLarge`), the managed
/// composition root's upstream build (`NoUsableUpstreams`) and its certificate /// file the operator named and this process cannot open
/// load (`BadCertificate`). /// (`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 /// `ManagedConfigUnreadable` is the only place a missing or unreadable path is a
/// the database rather than the content of a file, and is the one config-shaped /// configuration fault, and it is deliberately not `FileNotFound` itself: the
/// exit 2 `cli` decides for itself. /// 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{ const ConfigFault = validate.ValidateError || error{
ParseZon, ParseZon,
ConfigTooLarge, ConfigTooLarge,
ManagedConfigUnreadable,
NoUsableUpstreams, NoUsableUpstreams,
BadCertificate, BadCertificate,
}; };
@@ -94,6 +103,14 @@ test "the faults raised outside the validator are configuration faults" {
try testing.expect(isConfigFault(error.BadCertificate)); 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" { 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 // D1 verbatim: these three reached `run` from a rejected seed file and were
// classified as runtime failures. // 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.AccessDenied));
try testing.expect(!isConfigFault(error.FileNotFound)); try testing.expect(!isConfigFault(error.FileNotFound));
try testing.expect(!isConfigFault(error.AddressInUse)); try testing.expect(!isConfigFault(error.AddressInUse));
// A state conflict, not a bad file: `import` refuses to overwrite a // A verdict on the diff, not on the file: `import` refuses a run that would
// configured database and decides that exit code itself. // delete rows and decides that exit code itself.
try testing.expect(!isConfigFault(error.DatabaseNotEmpty)); try testing.expect(!isConfigFault(error.DestructiveImport));
// Only ever a warning, so it never reaches an exit code by this route. // Only ever a warning, so it never reaches an exit code by this route.
try testing.expect(!isConfigFault(error.SourceInNoGroup)); try testing.expect(!isConfigFault(error.SourceInNoGroup));
} }
+266 -673
View File
File diff suppressed because it is too large Load Diff
+323
View File
@@ -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
View File
@@ -87,10 +87,16 @@ pub const Web = struct {
enabled: bool = true, enabled: bool = true,
bind: []const u8 = "0.0.0.0", bind: []const u8 = "0.0.0.0",
port: u16 = 8080, port: u16 = 8080,
/// Operator input only. Never a settings row, always exported as "". /// Operator input only. Never a settings row, never exported.
password: []const u8 = "", ///
/// argon2id PHC string; "" disables authentication. /// Optional because absence and emptiness are different declarations: null
password_hash: []const u8 = "", /// 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, session_ttl_hours: u16 = 24,
api_rate_limit_per_min: u32 = 300, api_rate_limit_per_min: u32 = 300,
/// Requests from the box itself skip the API rate limit. On by default: a /// 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"; return @typeInfo(T) == .@"struct";
} }
/// `web.password` is operator input, never a settings row: it is hashed into /// The skip policy splits by direction, because encode and decode need
/// `web.password_hash` at import time and discarded (S2.5). /// different sets.
fn isSkipped(comptime section: []const u8, comptime field: []const u8) bool { ///
/// `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"); 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, .int => std.fmt.parseInt(T, text, 10) catch error.BadSettingValue,
.@"enum" => T.fromDb(text) orelse error.BadSettingValue, .@"enum" => T.fromDb(text) orelse error.BadSettingValue,
.pointer => text, .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)), 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)) { if (comptime isScalarSection(section_field.type)) {
const section = @field(cfg, section_field.name); const section = @field(cfg, section_field.name);
inline for (@typeInfo(section_field.type).@"struct".fields) |field| { 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); const value = try encodeValue(field.type, @field(section, field.name), gpa);
errdefer gpa.free(value); errdefer gpa.free(value);
try out.append(gpa, .{ .key = section_field.name ++ "." ++ field.name, .value = 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| { inline for (@typeInfo(Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) { if (comptime isScalarSection(section_field.type)) {
inline for (@typeInfo(section_field.type).@"struct".fields) |field| { 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)) { if (std.mem.eql(u8, pair.key, section_field.name ++ "." ++ field.name)) {
@field(@field(cfg, section_field.name), field.name) = @field(@field(cfg, section_field.name), field.name) =
try decodeValue(field.type, pair.value); try decodeValue(field.type, pair.value);
@@ -531,7 +555,6 @@ const expected_keys = [_][]const u8{
"web.api_rate_limit_per_min", "web.api_rate_limit_per_min",
"web.bind", "web.bind",
"web.enabled", "web.enabled",
"web.password_hash",
"web.port", "web.port",
"web.session_ttl_hours", "web.session_ttl_hours",
"web.sse_max_connections_per_ip", "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| { inline for (@typeInfo(Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) { if (comptime isScalarSection(section_field.type)) {
inline for (@typeInfo(section_field.type).@"struct".fields) |field| { 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 a = @field(@field(original, section_field.name), field.name);
const b = @field(@field(restored, section_field.name), field.name); const b = @field(@field(restored, section_field.name), field.name);
if (comptime @typeInfo(field.type) == .pointer) { 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); 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" { test "a malformed settings value is BadSettingValue" {
var cfg: Config = .{}; var cfg: Config = .{};
var unknown: usize = 0; var unknown: usize = 0;
File diff suppressed because it is too large Load Diff
+56 -2
View File
@@ -101,6 +101,7 @@ pub const ValidateError = error{
MissingKeyPath, MissingKeyPath,
MissingLogPath, MissingLogPath,
PasswordAndHashBothSet, PasswordAndHashBothSet,
EmptyWebPassword,
}; };
/// What `validate` returns: a verdict on the configuration, or the allocation /// 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, /// 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 /// plus the warnings — which are never returned by `validate` and so are not
/// `ValidateError` members. /// `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. /// `.fail` rejects the configuration and is what an exit code is computed from.
/// `.warn` reports something legal that is almost certainly not what the /// `.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 checkBind(diags, cfg.web.bind, "web.bind", .any);
try checkPort(diags, cfg.web.port, "web.port"); try checkPort(diags, cfg.web.port, "web.port");
if (cfg.web.password.len != 0 and cfg.web.password_hash.len != 0) { // 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( try diags.add(
error.PasswordAndHashBothSet, error.PasswordAndHashBothSet,
"web.password", "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. // A session TTL is a TTL; `BadTtl` is its bucket.
if (cfg.web.session_ttl_hours < 1) { if (cfg.web.session_ttl_hours < 1) {
try diags.add(error.BadTtl, "web.session_ttl_hours", .{}, "must be at least 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"); 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" { test "a config with five distinct problems yields five diagnostics and the first error" {
var cfg = baseConfig(); var cfg = baseConfig();
cfg.dns.port = 0; // BadPort, first in check order cfg.dns.port = 0; // BadPort, first in check order
+113
View File
@@ -22,6 +22,7 @@ const build_options = @import("build_options");
const net = std.Io.net; const net = std.Io.net;
const model = @import("../config/model.zig"); const model = @import("../config/model.zig");
const reconcile = @import("../config/reconcile.zig");
const db = @import("../storage/db.zig"); const db = @import("../storage/db.zig");
const migrations = @import("../storage/migrations.zig"); const migrations = @import("../storage/migrations.zig");
const context = @import("../storage/repositories/context.zig"); const context = @import("../storage/repositories/context.zig");
@@ -400,6 +401,10 @@ const HttpFixture = struct {
server: net.Server, server: net.Server,
body: []const u8, body: []const u8,
route: std.atomic.Value(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 /// 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` /// prove the reply really left this server in pieces, because a `Writer`
/// reports a buffered part as written and would otherwise hide a fixture /// 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 }), .server = try local.listen(io, .{ .reuse_address = true }),
.body = body, .body = body,
.route = .init(@intFromEnum(Route.body)), .route = .init(@intFromEnum(Route.body)),
.accepted = .init(0),
.flushed_parts = .init(0), .flushed_parts = .init(0),
.stall_reached = .unset, .stall_reached = .unset,
.stall_release = .unset, .stall_release = .unset,
@@ -449,6 +455,7 @@ const HttpFixture = struct {
while (true) { while (true) {
var stream = self.server.accept(io) catch return; var stream = self.server.accept(io) catch return;
defer stream.close(io); defer stream.close(io);
_ = self.accepted.fetchAdd(1, .monotonic);
var read_buf: [8192]u8 = undefined; var read_buf: [8192]u8 = undefined;
var write_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);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 1112: local records, from the database to the wire // 1112: local records, from the database to the wire
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+42
View File
@@ -1199,6 +1199,14 @@ pub const Manager = struct {
if (state != .ok) return true; if (state != .ok) return true;
const last = row.last_updated orelse 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); 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); 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" { test "the disk gate skips a scheduled refresh only while writes are critical" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{}); var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit(); defer threaded.deinit();
+5 -3
View File
@@ -965,10 +965,12 @@ test "S7 case 11: the app boots, serves a query and exits zero on shutdown" {
shutdown.reset(); shutdown.reset();
defer shutdown.reset(); defer shutdown.reset();
var future = try test_io.concurrent(app.run, .{ runner, cli.RunArgs{ .paths = .{ // `--config` present: the file is authority and the database is converged
.data_dir = root, // 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, .config = config_path,
} } }); } });
const client_address: net.IpAddress = try .parse("127.0.0.1", 0); const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
const client = try client_address.bind(test_io, .{ .mode = .dgram }); const client = try client_address.bind(test_io, .{ .mode = .dgram });
+24 -21
View File
@@ -1,5 +1,5 @@
//! The `config.db` schema, verbatim from PLAN §11.2, plus the two table orders //! The `config.db` schema, verbatim from PLAN §11.2, plus the table lists every
//! every other storage session needs. //! other storage session needs.
//! //!
//! The DDL text is data, not code: `migrations.zig` carries it as step 1 and //! 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 //! 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); \\CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
; ;
/// Child-before-parent. Used by import's wipe step; correct under /// Child-before-parent, and correct under `foreign_keys = ON`. The reconcile
/// `foreign_keys = ON`. /// 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 /// `upstreams`, `local_records`, `forward_zones` and `settings` have no foreign
/// keys, so their position is free; `groups` and `blocklist_sources` must come /// 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", "blocklist_sources", "groups",
}; };
/// Every table whose emptiness defines "the database has never been configured" /// Every table that holds configuration, in a fixed order. It includes
/// (S5.2). `groups` is absent because migration step 1 seeds `(1, 'default')`, /// `groups`, because a byte-stability dump has to be able to see a reconcile
/// so an empty database still holds one group row; `schema_version` is absent /// that renumbered a group.
/// for the same reason. ///
pub const content_tables = [_][]const u8{ /// `schema_version` is absent: it is the migration's, not the operator's.
"clients", "client_prefixes", "upstreams", "blocklist_sources", pub const table_names = [_][]const u8{
"group_sources", "rules", "local_records", "forward_zones", "groups", "clients", "client_prefixes", "upstreams",
"settings", "blocklist_sources", "group_sources", "rules", "local_records",
"forward_zones", "settings",
}; };
const testing = std.testing; 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" { test "delete_order lists every referrer before the table it references" {
// The two parents in the schema. Every child that references them must be // 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 // 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 { fn indexOf(haystack: []const []const u8, needle: []const u8) ?usize {
for (haystack, 0..) |item, i| { for (haystack, 0..) |item, i| {
if (std.mem.eql(u8, item, needle)) return i; if (std.mem.eql(u8, item, needle)) return i;
+10
View File
@@ -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_column_bytes(stmt: *c.Stmt, col: c_int) c_int;
pub extern fn sqlite3_last_insert_rowid(db: *Sqlite3) i64; pub extern fn sqlite3_last_insert_rowid(db: *Sqlite3) i64;
pub extern fn sqlite3_changes(db: *Sqlite3) c_int; 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). /// Result codes, from the vendored `sqlite3.h` (3.53.4).
@@ -429,6 +430,15 @@ pub const Db = struct {
pub fn changes(self: *Db) i64 { pub fn changes(self: *Db) i64 {
return c.sqlite3_changes(self.handle); 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 { fn openHandle(filename: [:0]const u8, flags: c_int) Error!*c.Sqlite3 {
+2 -2
View File
@@ -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)); 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(); var database = try openMigrated();
defer database.close(); defer database.close();
_ = try migrate(&database); _ = 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| { for (config_schema.delete_order) |name| {
try testing.expect(try tableExists(&database, 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)); try testing.expect(try tableExists(&database, name));
} }
// delete_order covers every table except `schema_version`. // delete_order covers every table except `schema_version`.
+69 -7
View File
@@ -3,13 +3,14 @@
//! `listClients` returns only `hand_edited = 1` rows. A client the server //! `listClients` returns only `hand_edited = 1` rows. A client the server
//! materialised from live traffic is runtime state, not configuration, and must //! 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 //! 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 //! in this table, so it also decides what the reconcile engine may delete: a
//! carrying nothing but materialised rows has never been configured, and a seed //! declared row the file drops is removed, an observed row is kept whatever the
//! file must still be able to fill it. `countClients` counts **all** rows and is //! file says, and declaring an observed address promotes that row in place.
//! a test helper — it deliberately does not answer that question. //! `countClients` counts **all** rows and is a test helper.
//! //!
//! The import path is list / insert / deleteAll / count, plus the two runtime //! The configuration path is list / insert / update / delete / count, plus the
//! calls `upsertSeen` and `pruneStale` that `server/clients.zig`'s tracker owns. //! 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 //! The REST surface is the third section: it speaks row ids and shows
//! every client, materialised ones included. //! 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. /// 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 { pub fn countClients(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM clients"); return database.queryInt("SELECT count(*) FROM clients");
} }
@@ -407,6 +408,67 @@ pub fn replaceClientPrefixes(database: *db.Db, items: []const ClientPrefixInput)
try tx.commit(); 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 // tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+35
View File
@@ -254,6 +254,41 @@ pub fn setGroupSources(database: *db.Db, group_id: i64, source_ids: []const i64)
try tx.commit(); 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 { fn groupExists(database: *db.Db, id: i64) db.Error!bool {
var stmt = try database.prepare("SELECT 1 FROM groups WHERE id = ?1"); var stmt = try database.prepare("SELECT 1 FROM groups WHERE id = ?1");
defer stmt.deinit(); defer stmt.deinit();
@@ -88,6 +88,18 @@ pub fn putSetting(database: *db.Db, key: []const u8, value: []const u8) db.Error
try stmt.exec(); 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 // tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+20 -135
View File
@@ -22,7 +22,6 @@ const build_options = @import("build_options");
const Writer = std.Io.Writer; const Writer = std.Io.Writer;
const cli = @import("../cli.zig"); const cli = @import("../cli.zig");
const bootstrap = @import("../config/bootstrap.zig");
const config_export = @import("../config/export.zig"); const config_export = @import("../config/export.zig");
const import = @import("../config/import.zig"); const import = @import("../config/import.zig");
const model = @import("../config/model.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 }; 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); var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit(); defer diags.deinit();
return import.importFile( return import.importFile(
@@ -136,7 +135,7 @@ fn importInto(f: *const Fixture, data: *Data, file: []const u8, force: bool) !vo
&data.database, &data.database,
f.tmp.dir, f.tmp.dir,
file, file,
.{ .force = force }, .{ .allow_delete = allow_delete },
&diags, &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" { 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); 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; if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init(); var f: Fixture = .init();
@@ -716,7 +715,10 @@ test "S7 case 13: import refuses a configured database unless --force is given"
defer data.deinit(); defer data.deinit();
try importInto(&f, &data, "first.zon", false); 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( try testing.expectEqual(
@as(i64, 1), @as(i64, 1),
try data.database.queryInt( try data.database.queryInt(
@@ -753,14 +755,15 @@ test "S7 case 14: an invalid import reports every problem and writes nothing" {
&data.database, &data.database,
f.tmp.dir, f.tmp.dir,
"config.zon", "config.zon",
.{ .force = false }, .{ .allow_delete = false },
&diags, &diags,
)) |_| { )) |_| {
return error.TestUnexpectedResult; return error.TestUnexpectedResult;
} else |_| {} } else |_| {}
try testing.expectEqual(@as(usize, 2), diags.problems.items.len); 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. // Nothing beyond the database and its sidecars was created.
var dir = try f.tmp.dir.openDir(io, "data", .{ .iterate = true }); 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" { test "S7 case 19: writeToFile replaces an existing file and restores mode 0600" {
if (!build_options.integration) return error.SkipZigTest; 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); 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. // configuration, and the case needs the problems to reach the database.
var data = try openMigrated(&f, "bad"); var data = try openMigrated(&f, "bad");
defer data.deinit(); 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( const code = cli.runCheck(
captured.runner(), captured.runner(),
.{ .paths = .{ .config = config_path }, .config_explicit = true }, .{ .config = config_path },
true, true,
); );
try testing.expectEqual(cli.exit_ok, code); 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( try testing.expect(std.mem.count(
u8, u8,
captured.out.written(), captured.out.written(),
"OK https://cloudflare-dns.com/dns-query\n", "OK upstreams[0] https://cloudflare-dns.com\n",
) == 1); ) == 1);
} }
+2 -1
View File
@@ -49,7 +49,8 @@ comptime {
_ = @import("storage/repositories/settings_repo.zig"); _ = @import("storage/repositories/settings_repo.zig");
_ = @import("config/export.zig"); _ = @import("config/export.zig");
_ = @import("config/import.zig"); _ = @import("config/import.zig");
_ = @import("config/bootstrap.zig"); _ = @import("config/loader.zig");
_ = @import("config/reconcile.zig");
_ = @import("cli.zig"); _ = @import("cli.zig");
_ = @import("storage/storage_integration_test.zig"); _ = @import("storage/storage_integration_test.zig");
_ = @import("filter/parsers.zig"); _ = @import("filter/parsers.zig");
+6 -2
View File
@@ -58,9 +58,12 @@ pub const cookie_attributes = "HttpOnly; SameSite=Lax; Path=/";
pub const max_password_len = 256; pub const max_password_len = 256;
/// Authentication is on exactly when a hash exists (ruling 17). An empty hash /// 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 { 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 { pub const Outcome = enum {
@@ -453,6 +456,7 @@ fn tokenOf(n: u8) [token_bytes]u8 {
test "authEnabled follows the presence of a hash" { test "authEnabled follows the presence of a hash" {
try testing.expect(!authEnabled(.{})); try testing.expect(!authEnabled(.{}));
try testing.expect(!authEnabled(.{ .password_hash = null }));
try testing.expect(!authEnabled(.{ .password_hash = "" })); try testing.expect(!authEnabled(.{ .password_hash = "" }));
try testing.expect(authEnabled(.{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" })); try testing.expect(authEnabled(.{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" }));
} }
+110 -1
View File
@@ -24,6 +24,7 @@ const Allocator = std.mem.Allocator;
const address = @import("../../platform/address.zig"); const address = @import("../../platform/address.zig");
const clients_repo = @import("../../storage/repositories/clients_repo.zig"); const clients_repo = @import("../../storage/repositories/clients_repo.zig");
const db = @import("../../storage/db.zig");
const http_util = @import("../http_util.zig"); const http_util = @import("../http_util.zig");
const model = @import("../../config/model.zig"); const model = @import("../../config/model.zig");
const mutations = @import("mutations.zig"); const mutations = @import("mutations.zig");
@@ -155,7 +156,76 @@ const resource = mutations.Resource(.{
pub const list = resource.list; pub const list = resource.list;
pub const get = resource.get; 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 /// 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. /// 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); 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" { test "the prefix list is replaced whole" {
var bench: mutations.Bench = undefined; var bench: mutations.Bench = undefined;
try bench.init(testing.allocator); try bench.init(testing.allocator);
+65 -14
View File
@@ -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 /// 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). /// 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 { fn FieldType(comptime T: type) type {
return switch (@typeInfo(T)) { return switch (@typeInfo(T)) {
.@"enum" => []const u8, .@"enum" => []const u8,
.optional => |info| FieldType(info.child),
else => T, else => T,
}; };
} }
@@ -321,16 +327,17 @@ pub fn applyPut(
// The password never becomes a row: the hash made above is what the merged // The password never becomes a row: the hash made above is what the merged
// configuration — and therefore the settings table — carries. // 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; 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 } }; if (try problem(arena, cfg)) |text| return .{ .fail = .{ .invalid = text } };
// The gpa copy the live holder will own, made before the write so a // The gpa copy the live holder will own, made before the write so a
// committed transaction can never be followed by a failed revocation. // 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 merged_hash = cfg.web.password_hash orelse "";
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, cfg.web.password_hash) else null; 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| { writeSettings(arena, database, cfg) catch |err| {
if (replacement) |hash| state.gpa.free(hash); 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| { for (pairs.items) |pair| {
try settings_repo.putSetting(database, pair.key, pair.value); 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(); try tx.commit();
} }
@@ -455,6 +468,36 @@ pub const hash_stall_control = if (builtin.is_test) struct {
// routes // 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 { pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const database = mutations.requireConfigDb(state) catch const database = mutations.requireConfigDb(state) catch
return mutations.respondFailure(request, mutations.no_config_db, "reading the settings"); 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| const cfg = loaded catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading the settings"); 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 { 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)) { return switch (try applyPut(state, io, request.arena, parsed.value)) {
.fail => |failure| mutations.respondFailure(request, failure, "writing the settings"), .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, .{ return http_util.respondJson(request, status, .{
.settings = view(cfg), .settings = view(cfg),
.restart_required = restart_required_keys, .restart_required = restart_required_keys,
.authority = authorityView(state),
}, &.{}); }, &.{});
} }
@@ -498,8 +547,10 @@ const testing = std.testing;
const auth_handlers = @import("auth.zig"); const auth_handlers = @import("auth.zig");
test "the restart-required table lists every settings key and no secret" { 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 // `model.toSettings` is the other half of the same fact. The two lists are
// database stores, minus the hash the API never serializes. // 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; var pairs: std.ArrayList(model.SettingPair) = .empty;
defer { defer {
model.freeSettings(testing.allocator, pairs.items); 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 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| { 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_hash"));
try testing.expect(!std.mem.eql(u8, key, "web.password")); 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" }; patch.web = .{ .password = "correct horse battery staple" };
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch); 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)); try testing.expect(!sessions.validateAt(bench.io(), &cookie, 1_001));
// The plain password is nowhere in the table, and the hash is. // 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'"), try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.password'"),
); );
const stored = try mutations.loadConfig(bench.arena(), &bench.database); 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( try testing.expectEqual(
auth.Outcome.ok, 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 = "" }; patch.web = .{ .password = "" };
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch); 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" { test "reading the settings with no database is unavailable" {
+25 -10
View File
@@ -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 /// 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). /// 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( pub fn respondError(
request: *Request, request: *Request,
status: http.Status, status: http.Status,
message: []const u8, message: []const u8,
) HandlerError!void { ) HandlerError!void {
var buf: [512]u8 = undefined; var allocating: std.Io.Writer.Allocating = .init(request.arena);
var writer: std.Io.Writer = .fixed(&buf); defer allocating.deinit();
var stringify: std.json.Stringify = .{ .writer = &writer }; var stringify: std.json.Stringify = .{ .writer = &allocating.writer };
stringify.beginObject() catch return respondPlain(request, status, message); stringify.beginObject() catch return error.OutOfMemory;
stringify.objectField("error") catch return respondPlain(request, status, message); stringify.objectField("error") catch return error.OutOfMemory;
stringify.write(message) catch return respondPlain(request, status, message); stringify.write(message) catch return error.OutOfMemory;
stringify.endObject() catch return respondPlain(request, status, message); stringify.endObject() catch return error.OutOfMemory;
return respondBytes(request, status, writer.buffered(), content_type_json, &.{}); return respondBytes(request, status, allocating.written(), content_type_json, &.{});
} }
fn respondPlain(request: *Request, status: http.Status, message: []const u8) HandlerError!void { /// Milestone-20 ruling 7's rejection: a configuration write under file
return respondBytes(request, status, message, content_type_text, &.{}); /// 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 /// Serialises `value` and responds. The document is built in the request arena
+99 -1
View File
@@ -29,6 +29,15 @@ info:
- Mutations to groups, blocklists, rules, local records, forward zones, - Mutations to groups, blocklists, rules, local records, forward zones,
clients and client prefixes take effect live. Upstreams and clients and client prefixes take effect live. Upstreams and
`/api/settings` are restart-required. `/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: servers:
- url: / - url: /
@@ -391,6 +400,8 @@ paths:
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409": "409":
$ref: "#/components/responses/Conflict" $ref: "#/components/responses/Conflict"
"413": "413":
@@ -444,6 +455,8 @@ paths:
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"409": "409":
@@ -466,6 +479,8 @@ paths:
description: Deleted; applied live. description: Deleted; applied live.
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"409": "409":
@@ -519,6 +534,8 @@ paths:
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"409": "409":
@@ -575,6 +592,8 @@ paths:
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409": "409":
$ref: "#/components/responses/Conflict" $ref: "#/components/responses/Conflict"
"413": "413":
@@ -655,6 +674,8 @@ paths:
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"409": "409":
@@ -674,6 +695,8 @@ paths:
description: Deleted; applied live. description: Deleted; applied live.
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"409": "409":
@@ -728,6 +751,8 @@ paths:
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409": "409":
$ref: "#/components/responses/Conflict" $ref: "#/components/responses/Conflict"
"413": "413":
@@ -780,6 +805,8 @@ paths:
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"409": "409":
@@ -799,6 +826,8 @@ paths:
description: Deleted; applied live. description: Deleted; applied live.
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"409": "409":
@@ -853,6 +882,8 @@ paths:
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409": "409":
$ref: "#/components/responses/Conflict" $ref: "#/components/responses/Conflict"
"413": "413":
@@ -905,6 +936,8 @@ paths:
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"409": "409":
@@ -924,6 +957,8 @@ paths:
description: Deleted; applied live. description: Deleted; applied live.
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"409": "409":
@@ -978,6 +1013,8 @@ paths:
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409": "409":
$ref: "#/components/responses/Conflict" $ref: "#/components/responses/Conflict"
"413": "413":
@@ -1030,6 +1067,8 @@ paths:
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"409": "409":
@@ -1049,6 +1088,8 @@ paths:
description: Deleted; applied live. description: Deleted; applied live.
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"409": "409":
@@ -1133,6 +1174,8 @@ paths:
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"409": "409":
@@ -1222,6 +1265,8 @@ paths:
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409": "409":
$ref: "#/components/responses/Conflict" $ref: "#/components/responses/Conflict"
"413": "413":
@@ -1277,6 +1322,8 @@ paths:
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409": "409":
$ref: "#/components/responses/Conflict" $ref: "#/components/responses/Conflict"
"413": "413":
@@ -1330,6 +1377,8 @@ paths:
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"409": "409":
@@ -1350,6 +1399,8 @@ paths:
description: Deleted; takes effect on restart. description: Deleted; takes effect on restart.
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"409": "409":
@@ -1454,6 +1505,8 @@ paths:
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"413": "413":
$ref: "#/components/responses/BodyTooLarge" $ref: "#/components/responses/BodyTooLarge"
"429": "429":
@@ -1526,6 +1579,16 @@ components:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $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: NotFound:
description: No row has this id. description: No row has this id.
content: content:
@@ -2193,7 +2256,7 @@ components:
SettingsEnvelope: SettingsEnvelope:
type: object type: object
required: [settings, restart_required] required: [settings, restart_required, authority]
properties: properties:
settings: settings:
$ref: "#/components/schemas/Settings" $ref: "#/components/schemas/Settings"
@@ -2203,6 +2266,41 @@ components:
description: | description: |
Every `section.field` key that needs a restart to take effect — Every `section.field` key that needs a restart to take effect —
currently all of them. 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: SettingsPatch:
type: object type: object
+78
View File
@@ -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" { test "the document names the contract's fixed points" {
for ([_][]const u8{ for ([_][]const u8{
"openapi: 3.0.3", "openapi: 3.0.3",
+55 -7
View File
@@ -37,12 +37,20 @@ pub const Auth = enum { open, session };
/// monitoring endpoints so a Prometheus scrape can never be throttled. /// monitoring endpoints so a Prometheus scrape can never be throttled.
pub const RateLimit = enum { counted, exempt }; 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 { pub const RouteInfo = struct {
method: http.Method, method: http.Method,
/// Segments separated by `/`, with at most one `{id}` capture, which must /// Segments separated by `/`, with at most one `{id}` capture, which must
/// be a positive integer row id. /// be a positive integer row id.
pattern: []const u8, pattern: []const u8,
auth: Auth, auth: Auth,
/// No default: a new route states its class or does not compile.
policy: Policy,
handler: HandlerFn, handler: HandlerFn,
rate_limit: RateLimit = .counted, rate_limit: RateLimit = .counted,
}; };
@@ -158,6 +166,17 @@ pub fn dispatch(
return http_util.respondError(request, .unauthorized, "authentication required"); 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); return found.route.handler(state, io, request);
} }
@@ -196,13 +215,14 @@ fn noopHandler(
} }
const test_table = [_]RouteInfo{ const test_table = [_]RouteInfo{
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = noopHandler, .rate_limit = .exempt }, .{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = noopHandler, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = noopHandler }, .{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = noopHandler },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = noopHandler }, .{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler }, .{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler }, .{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler }, .{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .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 { 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)); 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" { 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.ptr, routes.ptr);
try testing.expectEqual(routes_table.table.len, routes.len); try testing.expectEqual(routes_table.table.len, routes.len);
+132 -56
View File
@@ -18,6 +18,17 @@
//! bucket. The static assets are ruling 18's remaining exemption; they are //! bucket. The static assets are ruling 18's remaining exemption; they are
//! not routes — the router sends unmatched non-`/api` paths to //! not routes — the router sends unmatched non-`/api` paths to
//! `WebState.fallback` before any policy check. //! `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"); const router = @import("router.zig");
@@ -43,85 +54,85 @@ const version = @import("handlers/version.zig");
pub const table: []const router.RouteInfo = &.{ pub const table: []const router.RouteInfo = &.{
// Monitoring and contract (ruling 18's open set, ruling 19's exemptions). // 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 = "/metrics", .auth = .open, .policy = .read, .handler = metrics.handle, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = health.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, .handler = version.handle }, .{ .method = .GET, .pattern = "/api/version", .auth = .open, .policy = .read, .handler = version.handle },
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .handler = openapi.handle }, .{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .policy = .read, .handler = openapi.handle },
// Authentication. // Authentication.
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .handler = auth.login }, .{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .policy = .runtime_action, .handler = auth.login },
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .handler = auth.logout }, .{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .policy = .runtime_action, .handler = auth.logout },
// Query log, stats, live stream, lookup. // Query log, stats, live stream, lookup.
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .handler = queries.list }, .{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .handler = queries.list },
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .handler = live.stream, .rate_limit = .exempt }, .{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .handler = live.stream, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .handler = stats.totals }, .{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .handler = stats.totals },
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .handler = stats.timeseries }, .{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .handler = stats.timeseries },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .handler = lookup.handle }, .{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .handler = lookup.handle },
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .handler = upstream_health.handle }, .{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .handler = upstream_health.handle },
// Groups. // Groups.
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = groups.list }, .{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = groups.list },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = groups.create }, .{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = groups.create },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.get }, .{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = groups.get },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.update }, .{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = groups.update },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.remove }, .{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = groups.remove },
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.getSources }, .{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .read, .handler = groups.getSources },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.putSources }, .{ .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 // Blocklist sources. `/api/blocklists/update` is a literal segment; it
// cannot collide with `{id}`, which only matches a positive integer. // cannot collide with `{id}`, which only matches a positive integer.
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.list }, .{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .policy = .read, .handler = blocklists.list },
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.create }, .{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .policy = .config_write, .handler = blocklists.create },
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .handler = blocklists.refresh }, .{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .policy = .runtime_action, .handler = blocklists.refresh },
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.get }, .{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .read, .handler = blocklists.get },
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.update }, .{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .handler = blocklists.update },
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.remove }, .{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .handler = blocklists.remove },
// Rules. // Rules.
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .handler = rules.list }, .{ .method = .GET, .pattern = "/api/rules", .auth = .session, .policy = .read, .handler = rules.list },
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .handler = rules.create }, .{ .method = .POST, .pattern = "/api/rules", .auth = .session, .policy = .config_write, .handler = rules.create },
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.get }, .{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .policy = .read, .handler = rules.get },
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.update }, .{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .handler = rules.update },
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.remove }, .{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .handler = rules.remove },
// Local records. // Local records.
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .handler = local.listRecords }, .{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .policy = .read, .handler = local.listRecords },
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .handler = local.createRecord }, .{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .policy = .config_write, .handler = local.createRecord },
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.getRecord }, .{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .read, .handler = local.getRecord },
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.updateRecord }, .{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .handler = local.updateRecord },
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.removeRecord }, .{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .handler = local.removeRecord },
// Forward zones. // Forward zones.
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .handler = local.listZones }, .{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .policy = .read, .handler = local.listZones },
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .handler = local.createZone }, .{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .policy = .config_write, .handler = local.createZone },
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.getZone }, .{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .read, .handler = local.getZone },
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.updateZone }, .{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .handler = local.updateZone },
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.removeZone }, .{ .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). // 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", .auth = .session, .policy = .read, .handler = clients.list },
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.get }, .{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .policy = .read, .handler = clients.get },
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.update }, .{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .policy = .config_write, .handler = clients.update },
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.remove }, .{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .policy = .runtime_action, .handler = clients.remove },
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.listPrefixes }, .{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .policy = .read, .handler = clients.listPrefixes },
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.putPrefixes }, .{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .policy = .config_write, .handler = clients.putPrefixes },
// Upstreams (restart-required resource). // Upstreams (restart-required resource).
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.list }, .{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .policy = .read, .handler = upstreams.list },
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.create }, .{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .policy = .config_write, .handler = upstreams.create },
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.get }, .{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .read, .handler = upstreams.get },
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.update }, .{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .handler = upstreams.update },
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.remove }, .{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .handler = upstreams.remove },
// Pause and settings. // Pause and settings.
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .handler = pause.get }, .{ .method = .GET, .pattern = "/api/pause", .auth = .session, .policy = .read, .handler = pause.get },
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .handler = pause.post }, .{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .handler = pause.post },
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .handler = settings.get }, .{ .method = .GET, .pattern = "/api/settings", .auth = .session, .policy = .read, .handler = settings.get },
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .handler = settings.put }, .{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .policy = .config_write, .handler = settings.put },
// Certificates (milestone-10 ruling 8). // 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); 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" { test "item routes capture one id and collection routes capture none" {
for (table) |route| { for (table) |route| {
const captures = std.mem.count(u8, route.pattern, "{id}"); const captures = std.mem.count(u8, route.pattern, "{id}");
+21
View File
@@ -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 /// 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 /// that finds the collaborator it needs missing answers 503, the same way it
/// answers a missing snapshot. /// 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 { pub const WebState = struct {
gpa: Allocator, gpa: Allocator,
web: model.Web = .{}, 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, handler: ?*dns_handler.Handler = null,
pause: ?*pause_mod.Pause = null, pause: ?*pause_mod.Pause = null,
tracker: ?*clients.Tracker = null, tracker: ?*clients.Tracker = null,
+5 -5
View File
@@ -90,11 +90,11 @@ fn bodyThenPathHandler(
} }
const test_routes = [_]router.RouteInfo{ const test_routes = [_]router.RouteInfo{
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = okHandler, .rate_limit = .exempt }, .{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = okHandler, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = okHandler }, .{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = okHandler },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = echoLengthHandler }, .{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = echoLengthHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = bodyThenPathHandler }, .{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = bodyThenPathHandler },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .open, .handler = echoDomainHandler }, .{ .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 { fn denyAll(state: *server.WebState, io: std.Io, request: *const http_util.Request) bool {
+313 -56
View File
@@ -264,6 +264,10 @@ const EnvOptions = struct {
sse_max_per_ip: u16 = 3, sse_max_per_ip: u16 = 3,
trusted_proxies: []const u8 = "", trusted_proxies: []const u8 = "",
fallback: ?router.HandlerFn = null, 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. /// 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, .sse_max_connections_per_ip = options.sse_max_per_ip,
.trusted_proxies = options.trusted_proxies, .trusted_proxies = options.trusted_proxies,
}, },
.authority = options.authority,
.reconciled_at = options.reconciled_at,
.live_hash = .init(options.password_hash), .live_hash = .init(options.password_hash),
.pause = &self.pauser, .pause = &self.pauser,
.manager = &self.mgr, .manager = &self.mgr,
@@ -573,6 +579,7 @@ const SettingsView = struct {
blocklist_update: struct { enabled: bool, interval_hours: u16 }, blocklist_update: struct { enabled: bool, interval_hours: u16 },
}, },
restart_required: []const []const u8, restart_required: []const []const u8,
authority: struct { mode: []const u8, path: ?[]const u8, reconciled_at: ?i64 },
}; };
const Contract = struct { const Contract = struct {
@@ -580,6 +587,9 @@ const Contract = struct {
/// Must equal a `routes.zig` pattern; the coverage test enforces it. /// Must equal a `routes.zig` pattern; the coverage test enforces it.
pattern: []const u8, pattern: []const u8,
auth: router.Auth, 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, rate_limit: router.RateLimit = .counted,
/// The concrete request target the walk sends. /// The concrete request target the walk sends.
target: []const u8, target: []const u8,
@@ -597,96 +607,96 @@ const Contract = struct {
/// create that made the row, and deletes come last for their resource. /// create that made the row, and deletes come last for their resource.
const contract = [_]Contract{ const contract = [_]Contract{
// Monitoring and 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 = "/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, .rate_limit = .exempt, .target = "/api/health", .status = 200, .check = jsonShape(handlers_health.Body) }, .{ .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, .target = "/api/version", .status = 200, .check = jsonShape(handlers_version.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, .target = "/api/openapi.yaml", .status = 200, .kind = .raw, .needle = "openapi: 3.0.3" }, .{ .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 // Authentication (auth is disabled in the walk's environment; the on/off
// matrix has its own test). // 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/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, .target = "/api/auth/logout", .status = 200, .check = jsonShape(LogoutView) }, .{ .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. // 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. // 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", .auth = .session, .policy = .read, .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/queries/live", .auth = .session, .policy = .read, .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", .auth = .session, .policy = .read, .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/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, .target = "/api/upstream/health", .status = 200, .check = jsonShape(handlers_upstream_health.Body) }, .{ .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 // Groups. The migrated schema seeds `default` as id 1; the POST creates
// id 2, which the delete at the end of the walk removes. // 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 = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .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 = .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, .target = "/api/groups/2", .status = 200, .check = jsonShape(groups_repo.GroupRow) }, .{ .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, .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200, .check = jsonShape(GroupEcho) }, .{ .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, .target = "/api/groups/1/sources", .status = 200, .check = jsonShape(SourceIds) }, .{ .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, .target = "/api/groups/1/sources", .body = "{\"source_ids\":[]}", .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 // Blocklist sources. The POST runs after the refresh above, so the created
// row's url is never fetched. // row's url is never fetched.
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .target = "/api/blocklists", .status = 200, .check = jsonShape(SourcesList) }, .{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .policy = .read, .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 = .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, .target = "/api/blocklists/1", .status = 200, .check = jsonShape(sources_repo.SourceRow) }, .{ .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, .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200, .check = jsonShape(SourceEcho) }, .{ .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, .target = "/api/blocklists/1", .status = 204, .kind = .none }, .{ .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 // Rules. The lookup below wants the blocking rule still in place, so the
// rule's delete follows it. // rule's delete follows it.
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .target = "/api/rules", .status = 200, .check = jsonShape(RulesList) }, .{ .method = .GET, .pattern = "/api/rules", .auth = .session, .policy = .read, .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 = .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, .target = "/api/rules/1", .status = 200, .check = jsonShape(RuleShape) }, .{ .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, .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 200, .check = jsonShape(RuleEcho) }, .{ .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, .target = "/api/lookup?domain=ads.example", .status = 200, .check = jsonShape(handlers_lookup.Body) }, .{ .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, .target = "/api/rules/1", .status = 204, .kind = .none }, .{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .target = "/api/rules/1", .status = 204, .kind = .none },
// Local records. // Local records.
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .target = "/api/local-records", .status = 200, .check = jsonShape(RecordsList) }, .{ .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, .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201, .check = jsonShape(RecordShape) }, .{ .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, .target = "/api/local-records/1", .status = 200, .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, .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .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, .target = "/api/local-records/1", .status = 204, .kind = .none }, .{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .target = "/api/local-records/1", .status = 204, .kind = .none },
// Forward zones. // Forward zones.
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .target = "/api/forward-zones", .status = 200, .check = jsonShape(ZonesList) }, .{ .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, .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201, .check = jsonShape(local_repo.ForwardZoneRow) }, .{ .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, .target = "/api/forward-zones/1", .status = 200, .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, .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .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, .target = "/api/forward-zones/1", .status = 204, .kind = .none }, .{ .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). // 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", .auth = .session, .policy = .read, .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 = .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, .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":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, .target = "/api/clients/1", .status = 204, .kind = .none }, .{ .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, .target = "/api/client-prefixes", .status = 200, .check = jsonShape(PrefixesList) }, .{ .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, .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .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 // Upstreams. Row id 1 is seeded; the POST creates id 2, whose delete
// cannot collide with the last-enabled-upstream guard. // cannot collide with the last-enabled-upstream guard.
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .target = "/api/upstreams", .status = 200, .check = jsonShape(UpstreamsList) }, .{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .policy = .read, .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 = .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, .target = "/api/upstreams/1", .status = 200, .check = jsonShape(upstreams_repo.UpstreamRow) }, .{ .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, .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200, .check = jsonShape(UpstreamEcho) }, .{ .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, .target = "/api/upstreams/2", .status = 204, .kind = .none }, .{ .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 // Pause and settings. The pause POST leaves filtering running; the
// settings PUT is a real change, echoed by the same response shape. // 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 = .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, .target = "/api/pause", .body = "{\"paused\":false}", .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, .target = "/api/settings", .status = 200, .check = jsonShape(SettingsView) }, .{ .method = .GET, .pattern = "/api/settings", .auth = .session, .policy = .read, .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 = .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 // Certificates. The walk's environment wires no cert store, so both
// endpoints report disabled — and the reload still answers 200 (m10 // endpoints report disabled — and the reload still answers 200 (m10
// ruling 8: the outcome is the payload). // 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. // 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 — // 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; covered[index] = true;
try testing.expectEqual(route.auth, entry.auth); try testing.expectEqual(route.auth, entry.auth);
try testing.expectEqual(route.rate_limit, entry.rate_limit); try testing.expectEqual(route.rate_limit, entry.rate_limit);
try testing.expectEqual(route.policy, entry.policy);
found = true; found = true;
break; 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 }); 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) // oversized cookie headers (ruling 7 of milestone 16)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+1
View File
@@ -1,3 +1,4 @@
dist/ dist/
dist-placeholder/ dist-placeholder/
package-lock.json package-lock.json
dist-sourcemap/
@@ -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 () => { 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 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 url = screen.getByLabelText("URL") as HTMLInputElement;
const name = screen.getByLabelText("Name") as HTMLInputElement; const name = screen.getByLabelText("Name") as HTMLInputElement;
fireEvent.change(url, { target: { value: "https://example.com/list.txt" } }); 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"); expect(name.value).toBe("Example");
const resolving = vi.fn(() => Promise.resolve()); 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" })); fireEvent.click(screen.getByRole("button", { name: "Add source" }));
await waitFor(() => expect(url.value).toBe("")); await waitFor(() => expect(url.value).toBe(""));
expect(name.value).toBe(""); expect(name.value).toBe("");
+10 -2
View File
@@ -3,6 +3,7 @@ import { ApiError } from "@/lib/api";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import type { Blocklist, BlocklistInput } from "@/lib/types"; import type { Blocklist, BlocklistInput } from "@/lib/types";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes"; 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 * Drops the rejection the page already renders inline below the form. Anything
@@ -17,12 +18,14 @@ export function swallowMutationError(error: unknown): void {
interface BlocklistFormProps { interface BlocklistFormProps {
initial?: Blocklist; initial?: Blocklist;
busy: boolean; busy: boolean;
/** File authority: the server answers 403, so the submit stays down. */
readOnly: boolean;
error: Error | null; error: Error | null;
onSubmit: (input: BlocklistInput) => Promise<void>; onSubmit: (input: BlocklistInput) => Promise<void>;
onCancel?: () => 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 [url, setUrl] = useState(initial?.url ?? "");
const [name, setName] = useState(initial?.name ?? ""); const [name, setName] = useState(initial?.name ?? "");
const [enabled, setEnabled] = useState(initial?.enabled ?? true); const [enabled, setEnabled] = useState(initial?.enabled ?? true);
@@ -81,7 +84,12 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
Enabled Enabled
</label> </label>
<div className="flex items-center gap-2"> <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"} {initial === undefined ? "Add source" : "Save changes"}
</button> </button>
{onCancel !== undefined && ( {onCancel !== undefined && (
+12 -3
View File
@@ -22,6 +22,7 @@ import {
tdClass, tdClass,
thClass, thClass,
} from "@/ui/classes"; } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
export default function BlocklistsPage() { export default function BlocklistsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -36,6 +37,9 @@ export default function BlocklistsPage() {
const sources = useRefreshStatus(); const sources = useRefreshStatus();
const namesById = new Map(blocklists.map((b) => [b.id, b.name])); 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) { async function submitForm(input: BlocklistInput) {
if (editing === null) { if (editing === null) {
@@ -122,7 +126,8 @@ export default function BlocklistsPage() {
type="checkbox" type="checkbox"
aria-label={`${b.name} enabled`} aria-label={`${b.name} enabled`}
checked={b.enabled} checked={b.enabled}
disabled={toggle.isPending} disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(b)} onChange={() => toggleEnabled(b)}
className={focusRing} className={focusRing}
/> />
@@ -138,14 +143,17 @@ export default function BlocklistsPage() {
<button <button
type="button" type="button"
onClick={() => setEditing(b)} onClick={() => setEditing(b)}
className={linkButtonClass} disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${linkButtonClass} disabled:opacity-50`}
> >
Edit Edit
</button> </button>
<button <button
type="button" type="button"
onClick={() => deleteBlocklist(b)} onClick={() => deleteBlocklist(b)}
disabled={remove.isPending} disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass} className={dangerLinkButtonClass}
> >
Delete Delete
@@ -164,6 +172,7 @@ export default function BlocklistsPage() {
key={editing?.id ?? "add"} key={editing?.id ?? "add"}
initial={editing ?? undefined} initial={editing ?? undefined}
busy={editing === null ? create.isPending : save.isPending} busy={editing === null ? create.isPending : save.isPending}
readOnly={readOnly}
error={formError} error={formError}
onSubmit={submitForm} onSubmit={submitForm}
onCancel={editing === null ? undefined : () => setEditing(null)} onCancel={editing === null ? undefined : () => setEditing(null)}
@@ -4,6 +4,7 @@ import { clientUpdateMutation } from "@/lib/queries";
import type { Client, Group } from "@/lib/types"; import type { Client, Group } from "@/lib/types";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { buttonClass, primaryButtonClass, smallInputClass } from "@/ui/classes"; import { buttonClass, primaryButtonClass, smallInputClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props { interface Props {
client: Client; client: Client;
@@ -18,6 +19,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
const mutation = useMutation(clientUpdateMutation(queryClient)); const mutation = useMutation(clientUpdateMutation(queryClient));
const [name, setName] = useState(client.name); const [name, setName] = useState(client.name);
const [groupId, setGroupId] = useState(client.group_id); const [groupId, setGroupId] = useState(client.group_id);
const readOnly = useReadOnlyConfig();
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"> <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}> <button type="button" onClick={onClose} className={buttonClass}>
Cancel Cancel
</button> </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 Save
</button> </button>
</div> </div>
+19 -2
View File
@@ -7,9 +7,17 @@ import ClientEditDialog from "./ClientEditDialog";
import PrefixesEditor from "./PrefixesEditor"; import PrefixesEditor from "./PrefixesEditor";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { smallButtonClass, tableWrapClass } from "@/ui/classes"; import { smallButtonClass, tableWrapClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const cellClass = "px-3 py-2"; 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() { export default function ClientsPage() {
const { data: clients } = useSuspenseQuery(clientsQuery()); const { data: clients } = useSuspenseQuery(clientsQuery());
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery()); const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
@@ -18,6 +26,7 @@ export default function ClientsPage() {
const deleteMutation = useMutation(clientDeleteMutation(queryClient)); const deleteMutation = useMutation(clientDeleteMutation(queryClient));
const [editing, setEditing] = useState<Client | null>(null); const [editing, setEditing] = useState<Client | null>(null);
const [confirmingId, setConfirmingId] = useState<number | null>(null); const [confirmingId, setConfirmingId] = useState<number | null>(null);
const readOnly = useReadOnlyConfig();
return ( return (
<section> <section>
@@ -86,14 +95,22 @@ export default function ClientsPage() {
<button <button
type="button" type="button"
onClick={() => setEditing(client)} onClick={() => setEditing(client)}
className={smallButtonClass} disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${smallButtonClass} disabled:opacity-50`}
> >
Edit Edit
</button> </button>
<button <button
type="button" type="button"
onClick={() => setConfirmingId(client.id)} 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 Delete
</button> </button>
+4 -1
View File
@@ -6,6 +6,7 @@ import { defaultGroupId } from "@/lib/defaultGroup";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor"; import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes"; import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props { interface Props {
prefixes: ClientPrefix[]; prefixes: ClientPrefix[];
@@ -19,6 +20,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
const [validation, setValidation] = useState<string | null>(null); const [validation, setValidation] = useState<string | null>(null);
const dirty = isDirty(state); const dirty = isDirty(state);
const fallbackGroupId = defaultGroupId(groups); const fallbackGroupId = defaultGroupId(groups);
const readOnly = useReadOnlyConfig();
const save = () => { const save = () => {
const problem = firstProblem(state.rows); const problem = firstProblem(state.rows);
@@ -105,7 +107,8 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
<button <button
type="button" type="button"
onClick={save} onClick={save}
disabled={!dirty || mutation.isPending} disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass} className={primaryButtonClass}
> >
Save prefixes Save prefixes
@@ -5,6 +5,7 @@ import type { Blocklist } from "@/lib/types";
import { sameSet, toggleSource } from "./sourceSet"; import { sameSet, toggleSource } from "./sourceSet";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass } from "@/ui/classes"; import { buttonClass, focusRing, primaryButtonClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props { interface Props {
groupId: number; groupId: number;
@@ -16,6 +17,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
const sources = useQuery(groupSourcesQuery(groupId)); const sources = useQuery(groupSourcesQuery(groupId));
const mutation = useMutation(groupSourcesPutMutation(queryClient)); const mutation = useMutation(groupSourcesPutMutation(queryClient));
const [selected, setSelected] = useState<number[] | null>(null); const [selected, setSelected] = useState<number[] | null>(null);
const readOnly = useReadOnlyConfig();
if (sources.isPending) { if (sources.isPending) {
return ( return (
@@ -58,7 +60,8 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
<button <button
type="button" type="button"
disabled={!dirty || mutation.isPending} disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onClick={() => onClick={() =>
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) }) mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
} }
+23 -7
View File
@@ -12,6 +12,7 @@ import GroupSourcesEditor from "./GroupSourcesEditor";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { focusRing, primaryButtonClass, smallButtonClass, smallInputClass } from "@/ui/classes"; import { focusRing, primaryButtonClass, smallButtonClass, smallInputClass } from "@/ui/classes";
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup"; 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."; const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
@@ -23,6 +24,7 @@ export default function GroupsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const createMutation = useMutation(groupCreateMutation(queryClient)); const createMutation = useMutation(groupCreateMutation(queryClient));
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const readOnly = useReadOnlyConfig();
return ( return (
<section> <section>
@@ -44,9 +46,15 @@ export default function GroupsPage() {
type="text" type="text"
value={newName} value={newName}
onChange={(event) => setNewName(event.target.value)} onChange={(event) => setNewName(event.target.value)}
disabled={readOnly}
className={smallInputClass} 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 Create
</button> </button>
</form> </form>
@@ -69,6 +77,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const isDefault = group.id === DEFAULT_GROUP_ID; const isDefault = group.id === DEFAULT_GROUP_ID;
const readOnly = useReadOnlyConfig();
const lockNote = isDefault ? DEFAULT_GROUP_NOTE : readOnly ? READ_ONLY_HINT : undefined;
return ( return (
<li className="rounded border border-zinc-200 p-4 dark:border-zinc-700"> <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} className={smallInputClass}
autoFocus 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 Save
</button> </button>
<button <button
@@ -115,7 +130,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
<input <input
type="checkbox" type="checkbox"
checked={group.safe_search} checked={group.safe_search}
disabled={updateMutation.isPending} disabled={updateMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={focusRing} className={focusRing}
onChange={(event) => onChange={(event) =>
updateMutation.mutate({ updateMutation.mutate({
@@ -138,8 +154,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
{!renaming && ( {!renaming && (
<button <button
type="button" type="button"
disabled={isDefault} disabled={isDefault || readOnly}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined} title={lockNote}
onClick={() => { onClick={() => {
setName(group.name); setName(group.name);
setRenaming(true); setRenaming(true);
@@ -168,8 +184,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
) : ( ) : (
<button <button
type="button" type="button"
disabled={isDefault} disabled={isDefault || readOnly}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined} title={lockNote}
onClick={() => setConfirming(true)} onClick={() => setConfirming(true)}
className={`${groupButtonClass} text-red-700 dark:text-red-400`} className={`${groupButtonClass} text-red-700 dark:text-red-400`}
> >
+31 -6
View File
@@ -17,18 +17,21 @@ import {
rowButtonClass, rowButtonClass,
tableWrapClass, tableWrapClass,
} from "@/ui/classes"; } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"]; const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
function RecordForm({ function RecordForm({
initial, initial,
busy, busy,
readOnly,
error, error,
onSubmit, onSubmit,
onCancel, onCancel,
}: { }: {
initial?: LocalRecord; initial?: LocalRecord;
busy: boolean; busy: boolean;
readOnly: boolean;
error: unknown; error: unknown;
onSubmit: (input: LocalRecordInput) => void; onSubmit: (input: LocalRecordInput) => void;
onCancel: () => void; onCancel: () => void;
@@ -109,7 +112,12 @@ function RecordForm({
/> />
</div> </div>
<div className="flex gap-2"> <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"} {busy ? "Saving…" : "Save"}
</button> </button>
<button type="button" onClick={onCancel} className={largeButtonClass}> <button type="button" onClick={onCancel} className={largeButtonClass}>
@@ -132,18 +140,31 @@ export default function RecordsTab() {
remove: localRecordDeleteMutation, remove: localRecordDeleteMutation,
confirmDelete: (record) => `Delete record "${record.name}"?`, confirmDelete: (record) => `Delete record "${record.name}"?`,
}); });
const readOnly = useReadOnlyConfig();
return ( return (
<div> <div>
<div className="mt-4 flex items-center justify-between"> <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> <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 Add record
</button> </button>
</div> </div>
<InlineError error={remove.error} /> <InlineError error={remove.error} />
{form?.mode === "create" && ( {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}> <div className={tableWrapClass}>
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
@@ -181,6 +202,7 @@ export default function RecordsTab() {
<RecordForm <RecordForm
initial={record} initial={record}
busy={update.isPending} busy={update.isPending}
readOnly={readOnly}
error={update.error} error={update.error}
onSubmit={onSubmit} onSubmit={onSubmit}
onCancel={closeForm} onCancel={closeForm}
@@ -196,15 +218,18 @@ export default function RecordsTab() {
<button <button
type="button" type="button"
onClick={() => openForm({ mode: "edit", entity: record })} onClick={() => openForm({ mode: "edit", entity: record })}
className={rowButtonClass} disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} disabled:opacity-50`}
> >
Edit Edit
</button> </button>
<button <button
type="button" type="button"
onClick={() => onDelete(record)} onClick={() => onDelete(record)}
disabled={remove.isPending} disabled={remove.isPending || readOnly}
className={`${rowButtonClass} text-red-600 dark:text-red-400`} title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`}
> >
Delete Delete
</button> </button>
+31 -6
View File
@@ -17,16 +17,19 @@ import {
rowButtonClass, rowButtonClass,
tableWrapClass, tableWrapClass,
} from "@/ui/classes"; } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
function ZoneForm({ function ZoneForm({
initial, initial,
busy, busy,
readOnly,
error, error,
onSubmit, onSubmit,
onCancel, onCancel,
}: { }: {
initial?: ForwardZone; initial?: ForwardZone;
busy: boolean; busy: boolean;
readOnly: boolean;
error: unknown; error: unknown;
onSubmit: (input: ForwardZoneInput) => void; onSubmit: (input: ForwardZoneInput) => void;
onCancel: () => void; onCancel: () => void;
@@ -70,7 +73,12 @@ function ZoneForm({
/> />
</div> </div>
<div className="flex gap-2"> <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"} {busy ? "Saving…" : "Save"}
</button> </button>
<button type="button" onClick={onCancel} className={largeButtonClass}> <button type="button" onClick={onCancel} className={largeButtonClass}>
@@ -93,6 +101,7 @@ export default function ZonesTab() {
remove: forwardZoneDeleteMutation, remove: forwardZoneDeleteMutation,
confirmDelete: (zone) => `Delete forward zone "${zone.zone}"?`, confirmDelete: (zone) => `Delete forward zone "${zone.zone}"?`,
}); });
const readOnly = useReadOnlyConfig();
return ( return (
<div> <div>
@@ -100,13 +109,25 @@ export default function ZonesTab() {
<p className="text-sm text-zinc-500"> <p className="text-sm text-zinc-500">
Names under these zones go to their own resolver. Changes apply live. Names under these zones go to their own resolver. Changes apply live.
</p> </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 Add zone
</button> </button>
</div> </div>
<InlineError error={remove.error} /> <InlineError error={remove.error} />
{form?.mode === "create" && ( {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}> <div className={tableWrapClass}>
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
@@ -138,6 +159,7 @@ export default function ZonesTab() {
<ZoneForm <ZoneForm
initial={zone} initial={zone}
busy={update.isPending} busy={update.isPending}
readOnly={readOnly}
error={update.error} error={update.error}
onSubmit={onSubmit} onSubmit={onSubmit}
onCancel={closeForm} onCancel={closeForm}
@@ -151,15 +173,18 @@ export default function ZonesTab() {
<button <button
type="button" type="button"
onClick={() => openForm({ mode: "edit", entity: zone })} onClick={() => openForm({ mode: "edit", entity: zone })}
className={rowButtonClass} disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} disabled:opacity-50`}
> >
Edit Edit
</button> </button>
<button <button
type="button" type="button"
onClick={() => onDelete(zone)} onClick={() => onDelete(zone)}
disabled={remove.isPending} disabled={remove.isPending || readOnly}
className={`${rowButtonClass} text-red-600 dark:text-red-400`} title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`}
> >
Delete Delete
</button> </button>
+10 -2
View File
@@ -6,6 +6,7 @@ import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from
import type { Rule, RuleAction, RuleKind } from "@/lib/types"; import type { Rule, RuleAction, RuleKind } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup"; import { defaultGroupId } from "@/lib/defaultGroup";
import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes"; import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
export default function RulesPage() { export default function RulesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -19,6 +20,7 @@ export default function RulesPage() {
const [kind, setKind] = useState<RuleKind>("exact"); const [kind, setKind] = useState<RuleKind>("exact");
const [action, setAction] = useState<RuleAction>("block"); const [action, setAction] = useState<RuleAction>("block");
const [groupId, setGroupId] = useState(() => defaultGroupId(groups)); const [groupId, setGroupId] = useState(() => defaultGroupId(groups));
const readOnly = useReadOnlyConfig();
function onSubmit(event: FormEvent<HTMLFormElement>) { function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
@@ -77,7 +79,8 @@ export default function RulesPage() {
<button <button
type="button" type="button"
onClick={() => deleteRule(rule)} onClick={() => deleteRule(rule)}
disabled={remove.isPending} disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass} className={dangerLinkButtonClass}
> >
Delete Delete
@@ -154,7 +157,12 @@ export default function RulesPage() {
</select> </select>
</div> </div>
</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"} {create.isPending ? "Creating…" : "Create rule"}
</button> </button>
<InlineError error={create.error} /> <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 -2
View File
@@ -5,6 +5,7 @@ import { settingsPutMutation, settingsQuery } from "@/lib/queries";
import { buildSettingsPatch } from "@/lib/settingsDiff"; import { buildSettingsPatch } from "@/lib/settingsDiff";
import type { Settings, SettingsPatch } from "@/lib/types"; import type { Settings, SettingsPatch } from "@/lib/types";
import { raiseRestartBanner } from "./restartBanner"; import { raiseRestartBanner } from "./restartBanner";
import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority";
import { focusRing } from "@/ui/classes"; import { focusRing } from "@/ui/classes";
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */ /** 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 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 { function setField(section: keyof Settings, key: string, value: unknown): void {
setEdited((prev) => ({ setEdited((prev) => ({
@@ -273,7 +275,7 @@ export default function SettingsPage() {
Changes are validated as a whole; every setting requires a restart to take effect. Changes are validated as a whole; every setting requires a restart to take effect.
</p> </p>
<form onSubmit={handleSubmit} className="mt-4 max-w-3xl"> <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 }) => ( {SECTIONS.map(({ section, title, fields }) => (
<fieldset key={section} className="rounded border border-zinc-200 p-4 dark:border-zinc-800"> <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> <legend className="px-1 text-sm font-semibold">{title}</legend>
@@ -339,6 +341,7 @@ export default function SettingsPage() {
<button <button
type="submit" type="submit"
disabled={saveDisabled} 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`} 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"} {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);
});
+25
View File
@@ -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";
}
+10 -2
View File
@@ -2,18 +2,21 @@ import { useState, type FormEvent } from "react";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import type { Upstream, UpstreamInput } from "@/lib/types"; import type { Upstream, UpstreamInput } from "@/lib/types";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes"; import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
import { READ_ONLY_HINT } from "@/features/settings/authority";
const DEFAULT_PRIORITY = "100"; const DEFAULT_PRIORITY = "100";
interface UpstreamFormProps { interface UpstreamFormProps {
initial?: Upstream; initial?: Upstream;
busy: boolean; busy: boolean;
/** File authority: the server answers 403, so the submit stays down. */
readOnly: boolean;
error: Error | null; error: Error | null;
onSubmit: (input: UpstreamInput) => Promise<void>; onSubmit: (input: UpstreamInput) => Promise<void>;
onCancel?: () => 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 [url, setUrl] = useState(initial?.url ?? "");
const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority)); const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority));
const [enabled, setEnabled] = useState(initial?.enabled ?? true); const [enabled, setEnabled] = useState(initial?.enabled ?? true);
@@ -97,7 +100,12 @@ export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel
Enabled Enabled
</label> </label>
<div className="flex items-center gap-2"> <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"} {initial === undefined ? "Add upstream" : "Save changes"}
</button> </button>
{onCancel !== undefined && ( {onCancel !== undefined && (
+10 -3
View File
@@ -6,6 +6,7 @@ import type { Upstream, UpstreamInput } from "@/lib/types";
import { raiseRestartBanner } from "../settings/restartBanner"; import { raiseRestartBanner } from "../settings/restartBanner";
import UpstreamForm from "./UpstreamForm"; import UpstreamForm from "./UpstreamForm";
import { dangerLinkButtonClass, focusRing, linkButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes"; import { dangerLinkButtonClass, focusRing, linkButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
export default function UpstreamsPage() { export default function UpstreamsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -16,6 +17,7 @@ export default function UpstreamsPage() {
const save = useMutation(upstreamUpdateMutation(queryClient)); const save = useMutation(upstreamUpdateMutation(queryClient));
const toggle = useMutation(upstreamUpdateMutation(queryClient)); const toggle = useMutation(upstreamUpdateMutation(queryClient));
const remove = useMutation(upstreamDeleteMutation(queryClient)); const remove = useMutation(upstreamDeleteMutation(queryClient));
const readOnly = useReadOnlyConfig();
async function submitForm(input: UpstreamInput) { async function submitForm(input: UpstreamInput) {
if (editing === null) { if (editing === null) {
@@ -84,7 +86,8 @@ export default function UpstreamsPage() {
type="checkbox" type="checkbox"
aria-label={`${u.url} enabled`} aria-label={`${u.url} enabled`}
checked={u.enabled} checked={u.enabled}
disabled={toggle.isPending} disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(u)} onChange={() => toggleEnabled(u)}
className={focusRing} className={focusRing}
/> />
@@ -95,14 +98,17 @@ export default function UpstreamsPage() {
<button <button
type="button" type="button"
onClick={() => setEditing(u)} onClick={() => setEditing(u)}
className={linkButtonClass} disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${linkButtonClass} disabled:opacity-50`}
> >
Edit Edit
</button> </button>
<button <button
type="button" type="button"
onClick={() => deleteUpstream(u)} onClick={() => deleteUpstream(u)}
disabled={remove.isPending} disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass} className={dangerLinkButtonClass}
> >
Delete Delete
@@ -121,6 +127,7 @@ export default function UpstreamsPage() {
key={editing?.id ?? "add"} key={editing?.id ?? "add"}
initial={editing ?? undefined} initial={editing ?? undefined}
busy={editing === null ? create.isPending : save.isPending} busy={editing === null ? create.isPending : save.isPending}
readOnly={readOnly}
error={formError} error={formError}
onSubmit={submitForm} onSubmit={submitForm}
onCancel={editing === null ? undefined : () => setEditing(null)} onCancel={editing === null ? undefined : () => setEditing(null)}
+10
View File
@@ -445,6 +445,11 @@ export const sample_post_pause: PauseState = {
}; };
export const sample_get_settings: SettingsEnvelope = { export const sample_get_settings: SettingsEnvelope = {
authority: {
mode: "database",
path: null,
reconciled_at: null,
},
restart_required: [ restart_required: [
"upstream.attempt_timeout_ms", "upstream.attempt_timeout_ms",
"upstream.read_timeout_ms", "upstream.read_timeout_ms",
@@ -563,6 +568,11 @@ export const sample_get_settings: SettingsEnvelope = {
}; };
export const sample_put_settings: SettingsEnvelope = { export const sample_put_settings: SettingsEnvelope = {
authority: {
mode: "database",
path: null,
reconciled_at: null,
},
restart_required: [ restart_required: [
"upstream.attempt_timeout_ms", "upstream.attempt_timeout_ms",
"upstream.read_timeout_ms", "upstream.read_timeout_ms",
+14
View File
@@ -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 { export interface SettingsEnvelope {
settings: Settings; settings: Settings;
restart_required: string[]; restart_required: string[];
authority: Authority;
} }
export interface TlsListenerPatch { export interface TlsListenerPatch {
+2
View File
@@ -5,6 +5,7 @@ import { useAuth } from "@/auth/store";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { versionQuery } from "@/lib/queries"; import { versionQuery } from "@/lib/queries";
import PauseWidget from "../features/pause/PauseWidget"; import PauseWidget from "../features/pause/PauseWidget";
import ReadOnlyConfigBanner from "../features/settings/ReadOnlyConfigBanner";
import RestartBanner from "../features/settings/RestartBanner"; import RestartBanner from "../features/settings/RestartBanner";
import { buttonClass, focusRing } from "@/ui/classes"; import { buttonClass, focusRing } from "@/ui/classes";
@@ -112,6 +113,7 @@ export default function AppShell() {
</div> </div>
</header> </header>
<RestartBanner /> <RestartBanner />
<ReadOnlyConfigBanner />
{drawerOpen && ( {drawerOpen && (
<div id="mobile-nav" className="border-b border-zinc-200 md:hidden dark:border-zinc-800"> <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"> <nav aria-label="Main" className="px-2 py-2">