From d76afc147ab759c86cde1b24ab92fff2b451efd9 Mon Sep 17 00:00:00 2001 From: m5r Date: Tue, 11 Aug 2026 23:31:40 +0200 Subject: [PATCH] milestone 20: declarative configuration for iac --- CHANGELOG.md | 40 + INSTALL.md | 28 +- README.md | 23 +- deploy/docker/compose.yaml | 15 +- deploy/systemd/nxdns.service | 8 + docs/README.md | 2 +- docs/explanation/architecture.md | 18 +- docs/explanation/configuration-model.md | 329 +-- docs/how-to/back-up-and-restore.md | 164 +- docs/how-to/enable-doh-and-dot.md | 10 +- docs/how-to/install-with-docker.md | 108 +- docs/how-to/install-with-systemd.md | 210 +- docs/how-to/set-up-admin-authentication.md | 107 +- docs/how-to/troubleshoot.md | 135 +- docs/how-to/upgrade.md | 206 +- docs/reference/api.md | 196 +- docs/reference/cli.md | 260 ++- docs/reference/configuration.md | 68 +- docs/reference/files-and-directories.md | 27 +- docs/tutorial/first-run.md | 134 +- specs/milestone-20.md | 71 +- src/app.zig | 513 ++++- src/cli.zig | 271 ++- src/config/bootstrap.zig | 141 -- src/config/export.zig | 67 +- src/config/faults.zig | 37 +- src/config/import.zig | 943 +++----- src/config/loader.zig | 323 +++ src/config/model.zig | 87 +- src/config/reconcile.zig | 1962 +++++++++++++++++ src/config/validate.zig | 58 +- src/filter/filter_integration_test.zig | 113 + src/filter/manager.zig | 42 + src/server/phase7_integration_test.zig | 8 +- src/storage/config_schema.zig | 45 +- src/storage/db.zig | 10 + src/storage/migrations.zig | 4 +- src/storage/repositories/clients_repo.zig | 76 +- src/storage/repositories/groups_repo.zig | 35 + src/storage/repositories/settings_repo.zig | 12 + src/storage/storage_integration_test.zig | 155 +- src/tests.zig | 3 +- src/web/auth.zig | 8 +- src/web/handlers/clients.zig | 111 +- src/web/handlers/settings.zig | 79 +- src/web/http_util.zig | 35 +- src/web/openapi.yaml | 100 +- src/web/openapi.zig | 78 + src/web/router.zig | 62 +- src/web/routes.zig | 188 +- src/web/server.zig | 21 + src/web/server_integration_test.zig | 10 +- src/web/web_integration_test.zig | 369 +++- web/.prettierignore | 1 + .../blocklists/BlocklistForm.test.tsx | 6 +- web/src/features/blocklists/BlocklistForm.tsx | 12 +- .../features/blocklists/BlocklistsPage.tsx | 15 +- web/src/features/clients/ClientEditDialog.tsx | 9 +- web/src/features/clients/ClientsPage.tsx | 21 +- web/src/features/clients/PrefixesEditor.tsx | 5 +- .../features/groups/GroupSourcesEditor.tsx | 5 +- web/src/features/groups/GroupsPage.tsx | 30 +- web/src/features/local/RecordsTab.tsx | 37 +- web/src/features/local/ZonesTab.tsx | 37 +- web/src/features/rules/RulesPage.tsx | 12 +- .../settings/ReadOnlyConfigBanner.tsx | 19 + web/src/features/settings/SettingsPage.tsx | 7 +- web/src/features/settings/authority.test.tsx | 254 +++ web/src/features/settings/authority.ts | 25 + web/src/features/upstreams/UpstreamForm.tsx | 12 +- web/src/features/upstreams/UpstreamsPage.tsx | 13 +- web/src/lib/contractSamples.gen.ts | 10 + web/src/lib/types.ts | 14 + web/src/shell/AppShell.tsx | 2 + 74 files changed, 6722 insertions(+), 1949 deletions(-) delete mode 100644 src/config/bootstrap.zig create mode 100644 src/config/loader.zig create mode 100644 src/config/reconcile.zig create mode 100644 web/src/features/settings/ReadOnlyConfigBanner.tsx create mode 100644 web/src/features/settings/authority.test.tsx create mode 100644 web/src/features/settings/authority.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 486500f..7d43b91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,46 @@ subject rarely does. ## [Unreleased] +### Added + +- **Declarative configuration for IaC.** `nxdns run --config=` 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 ` changed meaning.** It used to seed + the database once and then ignore the file; it now makes the file the + authority on every boot, which deletes any configuration the file does not + declare — including edits made through the web UI since the seed. Before + upgrading a unit that carries `--config`: either drop the flag to keep the + database in charge, or adopt file mode with the sequence in the upgrade + guide. Order matters there: export the file with the NEW binary (stopped). +- **Breaking: 0.0.1 exports are refused by this version.** A 0.0.1 + `nxdns export` writes both `.password = ""` and the stored + `.password_hash`, and this version refuses a file that carries both. This + bites any old export — an adoption file or a configuration backup fed to + `nxdns import` alike. Fix an existing export by deleting its + `.password = ""` line (keep the `.password_hash` line). Take fresh backups + with the new binary. +- **Breaking: the offline password-change recipe changed.** Setting + `.password = "new"` together with `.password_hash = ""` is now refused + (empty `password_hash` is an explicit "disable authentication", and the two + fields cannot both be present). To change the password in the file: set + `.password` and delete the `.password_hash` line entirely. +- `nxdns import --force` is renamed `--allow-delete`. +- A fresh install no longer seeds from `/etc/nxdns/config.zon` by presence. + Use `nxdns import` once, or run in file mode with `--config`. + ## [0.0.1] - 2026-08-09 First release. Everything below is new. diff --git a/INSTALL.md b/INSTALL.md index cad5e64..7335dbb 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -41,10 +41,9 @@ Do not create `/var/lib/nxdns` or `/var/log/nxdns` by hand. The unit's `StateDirectory` and `LogsDirectory` settings make systemd create them on first start, `/var/lib/nxdns` at mode 0700 owned by `nxdns`. -## 2. Write the seed configuration +## 2. Write the configuration -nxdns starts from an empty database only if a configuration file tells it what -to forward to. Write `/etc/nxdns/config.zon`: +nxdns will not start with nothing to forward to. Write `/etc/nxdns/config.zon`: ```zon .{ @@ -76,16 +75,27 @@ A good file ends with `OK: no problems found`. Exit 2 means `check` found something to fix and printed every problem it found. The upstream probe sends a real query, so this needs working DNS on the host. -The file seeds the database once. From the second start onwards it is ignored -and the database is the configuration. The seed's `web.password` is hashed at -import time and the plaintext is never stored, so once you have logged in you -can delete the file: +Load it into the database: + +```sh +nxdns import /etc/nxdns/config.zon +``` + +The packaged unit runs `nxdns run` with no `--config`, so from here the database +is the configuration and nothing reads the file again. `web.password` is hashed +and the plaintext is never stored, so once you have logged in you can delete the +file: ```sh rm /etc/nxdns/config.zon ``` -A kept seed is not a backup. `nxdns export` is. +A kept file is not a backup. `nxdns export` is. + +To keep the file as the configuration instead — converged at every start, with +the UI refusing configuration edits — do not delete it, and add a drop-in that +appends `--config=/etc/nxdns/config.zon` to `ExecStart`. See +`docs/how-to/install-with-systemd.md`. ## 3. Start it @@ -107,7 +117,7 @@ dig @ example.com A +short ``` The admin interface is on port 8080 by default; log in with the password from -the seed file. `http://:8080/api/health` reports upstream +the configuration file. `http://:8080/api/health` reports upstream availability and disk state without a login. ## More diff --git a/README.md b/README.md index 2d455bd..87e6c56 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ you what asked for what. - Blocklist filtering: subscribe to hosts/domain lists, plus your own allow and block rules with wildcard support (`*.example.com`) +- Two configuration modes: a database the web UI edits, or a ZON file you keep + in git and converge onto at every start - Per-client policy groups: different filtering for the kids' tablet and your workstation - Local DNS records and conditional forwarding for internal zones @@ -39,7 +41,7 @@ says what that signature does and does not prove. ## Quickstart (docker compose) -Seed a minimal configuration and start the published image. This is what the +Write a minimal configuration and start the published image. This is what the first release will make possible; it does not work today, because there is no image in the registry to pull: @@ -60,11 +62,20 @@ The compose file defaults to `:latest`; pin a version for anything you intend to keep running. To run it before a release exists, build the image yourself and name it — `NXDNS_IMAGE=nxdns docker compose up -d` — as [docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md) -describes. DNS is on port 53, the web UI on . The config -file seeds the database on first boot only; from then on the database is the -truth and changes go through the UI, the API, or `nxdns export` / -`nxdns import`. Full install instructions, including the systemd path and -the Pi 5 recipe, are in +describes. DNS is on port 53, the web UI on . + +The compose file runs `nxdns run --config=/etc/nxdns/config.zon`, which makes +that file the configuration: every start reconciles the database onto it, and +the UI refuses configuration edits. Edit the file and restart to change +anything. Drop the `command:` line to run bare `nxdns run` instead, where the +database is the configuration and changes go through the UI, the API, or +`nxdns export` / `nxdns import` — the packaged systemd unit does that. Which +mode is live is printed at every start (`authority: database` / +`authority: file ()`); see +[docs/explanation/configuration-model.md](docs/explanation/configuration-model.md). + +Full install instructions, including the systemd path and the Pi 5 recipe, are +in [docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) and [docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md). diff --git a/deploy/docker/compose.yaml b/deploy/docker/compose.yaml index 1836fe3..502a936 100644 --- a/deploy/docker/compose.yaml +++ b/deploy/docker/compose.yaml @@ -6,10 +6,17 @@ services: # NXDNS_IMAGE=nxdns. image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-latest}} restart: unless-stopped - # First boot needs ./etc-nxdns/config.zon with a `default` group and at - # least one enabled upstream, or the container exits with code 2. The file - # seeds the database once; after that the database is the truth and the - # file is ignored. + # `run --config` makes the file the sole source of configuration: nxdns + # reconciles the database onto ./etc-nxdns/config.zon at every start, and + # rejects configuration writes from the admin UI. Edit the file and restart + # the container to change anything. The file needs a `default` group and at + # least one enabled upstream, or the container exits with code 2. A + # recreated nxdns-data volume rebuilds itself from the file on next start. + # + # Drop this line to let the database be the truth instead, and load a first + # configuration once with: + # docker compose run --rm nxdns import /etc/nxdns/config.zon + command: ["run", "--config=/etc/nxdns/config.zon"] volumes: - ./etc-nxdns:/etc/nxdns:ro - nxdns-data:/var/lib/nxdns diff --git a/deploy/systemd/nxdns.service b/deploy/systemd/nxdns.service index 29c662c..3f017dc 100644 --- a/deploy/systemd/nxdns.service +++ b/deploy/systemd/nxdns.service @@ -16,6 +16,11 @@ StateDirectoryMode=0700 LogsDirectory=nxdns ConfigurationDirectory=nxdns +# ConfigurationDirectory creates /etc/nxdns owned by the service user. nxdns +# never writes there in either authority mode, and in file mode that directory +# holds the source of truth, so deny the write outright rather than rely on it. +ReadOnlyPaths=/etc/nxdns + # Port 53 (and 443/853 when the DoH/DoT listeners are enabled). AmbientCapabilities=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE @@ -44,6 +49,9 @@ SystemCallArchitectures=native Restart=on-failure RestartSec=2 +# Exit 2 is a configuration fault and 64 is a usage error. Neither clears on a +# retry, so a restart loop only buries the diagnostics already in the journal. +RestartPreventExitStatus=2 64 [Install] WantedBy=multi-user.target diff --git a/docs/README.md b/docs/README.md index c1b7ee2..1fcd813 100644 --- a/docs/README.md +++ b/docs/README.md @@ -65,7 +65,7 @@ are inspectable. - [explanation/architecture.md](explanation/architecture.md) — the module map and the design it comes from. - [explanation/configuration-model.md](explanation/configuration-model.md) — why - the file seeds the database once and the database is the truth afterwards. + there are two authority modes, how each one is selected, and what each is for. - [explanation/performance-and-testing.md](explanation/performance-and-testing.md) — why the targets exist, why CI does not gate on them, and what the hermetic tests do and do not prove. diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index 909dfd9..92326dd 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -35,7 +35,7 @@ Directories: | `src/upstream/` | Upstream resolution: shared vocabulary and the `Client` interface (`transport.zig`), DoH client (RFC 8484), DoT client (RFC 7858), per-endpoint health and backoff (`health.zig`), and `pool.zig` — priority-ordered failover that is itself a `transport.Client`, so the handler sees one interface. | | `src/server/` | The serving side: UDP/TCP/DoH/DoT listeners, `handler.zig` (the whole query pipeline), `cert_store.zig` (refcounted TLS cert holder), `rate_limiter.zig`, `pause.zig`, `clients.zig` (client auto-materialisation), `local_tables.zig` (published local-answer tables), `query_sink.zig` (log and SSE fanout), `shutdown.zig` (SIGINT/SIGTERM into one `std.Io.Event`). | | `src/storage/` | SQLite ownership: `db.zig` is the only file that calls SQLite, `config_schema.zig` + `migrations.zig` for `config.db`, `querylog_schema.zig` (open-or-recreate), async query `logger.zig`, `retention.zig`, `disk_monitor.zig`, and one repository per table under `repositories/`. | -| `src/config/` | The one configuration model (`model.zig`), the pure validator (`validate.zig`), `import.zig`/`export.zig` (ZON to and from `config.db`, byte-stable round trip), `bootstrap.zig` (first-start seeding — a policy wrapper over import). | +| `src/config/` | The one configuration model (`model.zig`), the pure validator (`validate.zig`), `import.zig`/`export.zig` (ZON to and from `config.db`, byte-stable round trip), `loader.zig` (read/parse/validate a named file, with the shared fault mapping), `reconcile.zig` (converge the database onto a parsed config by row identity). | | `src/web/` | The admin HTTP layer: `server.zig` (listener), `router.zig`/`routes.zig`, one file per resource under `handlers/`, `auth.zig` (sessions), `sse.zig` (live query fanout), `static.zig` (embedded SPA), `metrics.zig` (Prometheus), `openapi.zig` (served contract), `api_limiter.zig`, `http_util.zig`. | | `src/platform/` | OS and TLS edges: IP address values, the `std.log` sink (`logging.zig`), `statfs.zig` (free-space query via libc), client TLS over `std.crypto.tls` (`tls_client.zig`), server TLS over vendored Mbed TLS (`tls_server.zig`). | @@ -47,7 +47,7 @@ main.zig ── cli.zig ── app.zig (composition root) │ injects std.Io + collaborators ┌──────────────────────┴───────────────────────┐ │ server/ web/ upstream/ storage/ │ I/O edge - │ platform/ config/{import,export,bootstrap} │ + │ platform/ config/{loader,reconcile,import} │ ├──────────────────────────────────────────────┤ │ dns/ filter/* local/* cache/ │ pure core: │ config/{model,validate} │ bytes in, bytes out @@ -162,14 +162,20 @@ working resolver over a correct-looking failure. Two databases with opposite contracts, in one data directory (see [reference/files-and-directories.md](../reference/files-and-directories.md)). -**`config.db` is the truth.** Its schema is versioned: `migrations.zig` holds +**`config.db` is what the server reads.** Its schema is versioned: `migrations.zig` holds an ordered list of steps, step 1 being the verbatim DDL from `config_schema.zig`, each applied inside one transaction. `nxdns import` replaces the whole content atomically under `BEGIN IMMEDIATE`, so a failed import changes nothing; `nxdns export` renders it back as canonical ZON, -byte-identical across round trips. A config file seeds this database exactly -once at first start. Why it works that way is -[configuration-model.md](configuration-model.md). +byte-identical across round trips. + +Which of the file and the database is *authoritative* is chosen by the +invocation, not by state: bare `nxdns run` serves the database, and +`nxdns run --config FILE` makes the file authoritative and reconciles the +database onto it at every start. `reconcile.zig` is that convergence, matching +rows by identity and writing only differences, so runtime state — blocklist +checksums, compiled snapshots, client history — survives. Why it works that way +is [configuration-model.md](configuration-model.md). **`querylog.db` is expendable.** It is never migrated. Its schema carries a fingerprint derived from the DDL text, and at open, a missing, corrupt, diff --git a/docs/explanation/configuration-model.md b/docs/explanation/configuration-model.md index 938932e..cf75e09 100644 --- a/docs/explanation/configuration-model.md +++ b/docs/explanation/configuration-model.md @@ -1,8 +1,9 @@ # The configuration model nxdns is configured two ways — a ZON file and a web UI — and only one of them -can be the truth. This page explains which, and why that choice is the one -that leaves the fewest ways to lose an operator's work. +can be the truth at a time. This page explains how that choice is made, what +each mode is for, and why the design leaves the fewest ways to lose an +operator's work. For the fields themselves see [reference/configuration.md](../reference/configuration.md); for the commands @@ -10,146 +11,169 @@ and their exit codes see [reference/cli.md](../reference/cli.md). ## The rule -The database is the truth. The file is a seed. +**Authority is the invocation.** -`config.db` in the data directory holds the running configuration. The ZON -file (default `/etc/nxdns/config.zon`, overridable with `--config`) is read on -`nxdns run` only while the database is still empty: if the file exists and the -database holds no configuration, it is imported. Once the import has put rows -in, the file is not opened again. +``` +nxdns run the database is the truth +nxdns run --config /etc/nxdns/config.zon the file is the truth +``` -The condition is the state of the database, not a one-shot flag. A `run` whose -seed fails — unreadable file, parse error, failed validation, a constraint -violation inside the import transaction — leaves the database empty, so the -next `run` reads the file again. That is what makes fixing a typo and starting -again work. +That is the entire selection mechanism. There is no mode setting, no default +file path, and nothing recorded in the database about which mode last wrote it. +A `config.zon` that exists but that no invocation names changes nothing at all. -`src/config/bootstrap.zig` is that policy and nothing else — a wrapper over -the same import path `nxdns import` uses. Three outcomes: +Two properties fall out of that, and both were chosen on purpose. -- no file: the database is used as it is; -- file present, database empty: seed it; -- file present, database already configured: skip, without reading the file. +**An operator can read `ExecStart` and know which authority is live.** The +alternative — probe a well-known path, and behave differently depending on +whether a file happens to be there — is ambient magic. It is also the exact +class of rule that produced years of documentation lies in this project: the +old design read the file only while the database was empty, which meant the same +command did two different things depending on state nobody could see from the +command line, and every page that described it eventually described it wrongly. -A file that exists but is unreadable, unparseable or invalid fails the start, -with every problem printed. nxdns never falls back to silent defaults over a -file an operator wrote — a resolver that boots "successfully" with a -configuration nobody chose is the worst outcome available, because it looks -like it worked. +**A path already expresses a two-state choice, so a mode flag beside it would +be redundant and worse.** An earlier draft had `--config-source=db|file`. A mode +flag next to a path flag manufactures combinations that cannot mean anything — +a path with no mode, a mode with no path — and each one then needs a pairing +rule and a usage error to defend it. Presence-of-path has no invalid +combinations, so there is nothing to defend. -## Why the database wins +## Database mode -The alternative designs all lose data. +`nxdns run`. `config.db` holds the configuration; the UI, the API and `nxdns +import` write to it; nothing reads a file. This is the appliance: someone sets +the box up once, and afterwards the household member who wants to unblock one +domain clicks a button. -If the file were the truth, the admin UI could not write. Every change would -be an SSH session and a restart, which defeats the reason the UI exists: the -household member who wants to unblock one domain is not going to edit ZON. +A fresh install in this mode starts from an empty database, which fails +validation on its own terms — there is nowhere to forward a query to — and says +what to do about it: -If both were the truth, they would disagree. The UI writes a rule; the file -still says otherwise; the next restart either silently reverts the rule or -silently ignores the file. Both are silent, and both destroy work someone -intended to keep. There is no merge rule that fixes this, because the system -cannot know which of two conflicting statements is the newer intention. +``` +nxdns run failed: NoUsableUpstreams +run `nxdns check` to see the configuration in full +load one with `nxdns import `, or make a file the source of truth with `nxdns run --config ` +``` -So the file's authority ends the moment the database has content. Editing -`config.zon` after first boot does nothing — no partial effect, no warning -that some fields took and others did not. That is a blunt rule, and it is the -point: the failure mode is "my edit did nothing", which is visible the first -time you look at the UI, rather than "my edit was applied and then quietly -undone next Tuesday". +## File mode -The emptiness check is a real query over the content tables, not a flag: the -database counts as configured when a content table holds rows, or when the -default group has been altered. What it measures is intent, not activity, and -the client table is where those two come apart. Clients are auto-materialised -when they first send a query — the DNS path writes a row per device it sees — -and such a row records what the network did, not what an operator decided. It -carries `hand_edited = 0`, the emptiness check counts only the `hand_edited = 1` -rows, and `export` omits them. So answering queries never turns an unconfigured -database into a configured one; naming a device does. +`nxdns run --config FILE`. The file is the sole declarative source, and the +database becomes the runtime substrate: every start reads the file, validates +it, converges the database onto it, and serves from there. Configuration writes +through the API are refused with a 403. -Counting traffic here would have been a quiet trap: a server that resolved one -name would have declared itself configured and ignored a seed file placed -afterwards, and the operator would have had no line of output saying why. +This is the mode for a file kept in git and pushed by Ansible. What it buys is +that the deployed file is what is running — not "was imported once", not +"was imported unless someone clicked something since". -The same distinction survives an import. `import` replaces the content in one -transaction, which empties `clients` along with every other table, so every -client row is saved before the wipe. The materialised ones are put back after it -unchanged, and restoring a backup does not make the server forget the devices it -has met. +Three properties make it usable rather than merely correct. -An address the imported file names belongs to the file — the operator's -statement wins over the discovered row — with one carve-out. First-seen and -last-seen are not configuration: they record when a device was heard from, the -configuration model has no field for either, and an import is not a query. So -they follow the address rather than the row. If the database already knew that -address, its two timestamps are carried onto the new row; only an address the -database has never seen takes the import's clock. Without that, re-importing a -backup would stamp every device the operator had bothered to name as though it -had just arrived — and those are exactly the devices whose history is worth -something. +**It fails closed.** A file that is missing, unreadable, unparseable, oversized +or invalid stops the start. nxdns never falls back to the database, because a +fallback turns a deploy typo into a configuration that is silently months old +and looks fine. That failure is exit 2, so `nxdns check --config FILE` is a real +pre-restart gate: validate the pushed file in the handler, and a typo is a +failed deploy at noon rather than a dead resolver at the next power cut. + +**It converges rather than replaces.** Reconciling matches rows by identity and +writes only what differs. A source whose URL has not changed keeps its row id, +its checksum, its counters and its compiled blocklist files — so a restart in +file mode downloads nothing, which is the difference between a design that is +tolerable to restart and one that costs three minutes and 100 MB every time. + +**An unchanged file writes nothing at all.** Not "writes the same bytes" — +performs zero write statements, and reports it: + +``` +reconciled '/etc/nxdns/config.zon': no changes +``` + +That matters beyond elegance. A box whose SD card is full of query log can still +restart in file mode, because a no-op reconcile needs no write-ahead-log +headroom. + +**Converged at every boot is not a lock between boots.** Nothing stops `nxdns +import` or a `runtime action` route from moving the database while the server +runs. The contract is that the next start puts it back, and says what it +corrected. + +## What the file cannot take away + +The file is authoritative over configuration. It is not authoritative over +things it has no vocabulary for, and reconciling has to preserve those or the +mode is unusable. + +- **Blocklist download state.** Checksums, fetch timestamps and domain counts + belong to the network, not the operator. They survive on every matched row. +- **Client history.** Devices nxdns saw on the wire are kept whole. Naming one + in the file promotes that row in place — it keeps its first-seen and + last-seen and its row id, and counts as an update rather than a delete and an + insert. +- **Devices whose group is un-declared.** Remove a group from the file and the + observed clients assigned to it move to `default`. The operator un-declared + the group, not the devices. +- **The password, when the file does not mention it.** See + [the password](#the-password). + +The one thing identity cannot survive is a change to identity itself. Edit a +source's URL and the engine sees one row gone and one row arrived: new id, fresh +download, and the old compiled files swept. That is consistent — artifacts are +keyed by row id — and it is why the CLI asks for `--allow-delete` when a diff +deletes anything. + +## Why the database is the substrate in both modes + +Even in file mode the database is where the server reads its effective +configuration from. That is not a leftover; it is what lets one read path serve +both modes, and it is what makes the runtime state above have somewhere to live. + +If the file were read directly on every query path there would be no place to +keep a checksum, and no way for the UI to show anything. If both file and +database were authoritative there would be a two-way merge, and a merge cannot +work here: when the UI writes a rule and the file still says otherwise, nothing +in the system knows which of two statements is the newer intention. Every +resolution silently destroys work someone meant to keep. + +So the modes are exclusive, and the failure mode of each is loud. In database +mode, editing the file does nothing, which you find out the first time you look +at the UI. In file mode, the UI refuses the edit to your face with a message +naming the file to edit instead. ## The round trip -Losing the file as an editing surface would be a real loss — text is -diffable, reviewable and easy to back up — so the file is kept as a -*rendering* of the database rather than a rival to it. That is what -`export`/`import` are for: +The file stays useful as an editing surface in both modes — text is diffable, +reviewable and easy to back up — because `export` renders the database into the +same shape `import` and file mode read: ``` -nxdns export → canonical ZON → edit → nxdns import --force → config.db +nxdns export → canonical ZON → edit → nxdns import → config.db ``` -`nxdns export` renders the database as canonical ZON: a fixed two-line -header, every default emitted, deterministic ordering from the model's field -order and the repositories' `ORDER BY` clauses, and no timestamps or hostnames -anywhere. Runtime columns — first-seen, last-seen, per-source counters — are -absent from the configuration model on purpose, so two exports taken from a -live, busy server are identical. The round trip `export → import → export` is -byte-identical, and a test asserts it. +`nxdns export` writes canonical ZON: a fixed two-line header, every default +emitted, deterministic ordering from the model's field order and the +repositories' `ORDER BY` clauses, and no timestamps or hostnames anywhere. +Runtime columns are absent from the configuration model on purpose, so two +exports taken from a live, busy server are identical. `export → import → +export` is byte-identical, and a test asserts it. Byte-stability is not cosmetic. It is what makes an exported file usable in version control and what makes a diff of two exports mean something: any difference is a configuration change, never noise from when the export ran. -`nxdns import` replaces the whole database content in one transaction. Not a -merge, not a patch: delete every content table in foreign-key-safe order, then -insert what the file says — with the one exception described above. Every client -row is lifted out first. The materialised ones are put back rather than -recreated from a file that never held them, and the observed timestamps of an -address the file *does* name are merged onto its new row. A client the file -leaves out is gone, history included: nothing puts a `hand_edited = 1` row back. -A failed import — bad syntax, failed -validation, a constraint violation halfway through — leaves the database exactly -as it was, because everything happens inside a single `BEGIN IMMEDIATE`. +The output is also identical in both modes — nothing marks a file as coming +from a file-mode box. That is deliberate, because it is what makes export the +adoption tool: the file you check is the file you deploy, byte for byte. The +label an operator wants is in the unit file, where they put it. -Without `--force`, import refuses a database that already holds configuration. -That -check runs *inside* the transaction, after the lock is taken, so it cannot be -raced by a concurrent write. The effect is that a plain `import` can never -clobber a configured server by accident, and clobbering it deliberately takes -one visible extra word on the command line. See -[how-to/back-up-and-restore.md](../how-to/back-up-and-restore.md). - -## One model, three surfaces - -`src/config/model.zig` defines exactly one `Config` type. The ZON parser -produces it, the database reader produces it, the validator consumes it, the -composition root consumes it, and the settings API derives its key list and -its patch struct from its type information rather than mirroring the fields by -hand. Adding a field in one place therefore cannot leave the other surfaces -behind; the alternative — a file schema, a DB schema and an API schema kept in -step by discipline — is the standard way configuration systems rot. - -The model has two shapes of field, and the difference is structural rather -than stylistic. Struct-typed fields are scalar sections (`dns`, `cache`, -`web`, …) and live in a single key/value `settings` table as -`section.field` text rows. Slice-typed fields are collections (groups, -upstreams, clients, rules, local records, forward zones, …) and each gets its -own table with foreign keys. `toSettings` and `fromSettings` are the two -halves of the scalar bridge, generated by an `inline for` over the model, so -the key list is a consequence of the type rather than a second list to -maintain. +Reconciling the same file twice produces a byte-identical database — ids, +checksums, `created_at`, the password hash, the whole settings table — including +when the file is written in a non-canonical but equivalent form, such as +`FD00:0:0:0:0:0:0:1` for an address stored as `fd00::1`. Anything that churns +under an unchanged file is a bug in the engine by definition. That single +invariant is what forces most of the design above: matching on canonical forms, +writing only on difference, treating duplicate rule tuples as a multiset, and +verifying a password rather than re-hashing it. ## Unknown keys, and why the asymmetry is deliberate @@ -157,52 +181,65 @@ An unknown key in the **database** is warned about and ignored. An unknown key in the **file** is a hard error. They are different situations. A settings row the running binary does not -recognise is almost always an older binary reading a database written by a -newer one — a downgrade, or a rollback after a bad upgrade. Refusing to start -there would mean a downgrade bricks the config database, and the operator -would have to hand-edit SQLite to recover. Warning and ignoring means the -downgrade works, the unknown setting sits inert, and the upgrade back picks it -up again. +recognise is almost always an older binary reading a database written by a newer +one — a downgrade, or a rollback after a bad upgrade. Refusing to start there +would mean a downgrade bricks the config database, and the operator would have +to hand-edit SQLite to recover. Warning and ignoring means the downgrade works, +the unknown setting sits inert, and the upgrade back picks it up again. A key in a file, by contrast, is something a human just typed. The likeliest cause is a typo, and the second likeliest is a field that no longer exists. Silently ignoring it would mean the setting the operator believes they applied was never applied — exactly the silent-divergence failure the whole model is -built to avoid. So the ZON parser rejects unknown fields with a line and -column. +built to avoid. So the ZON parser rejects unknown fields with a line and column. The tolerance has a boundary worth stating plainly: it covers unknown *keys*, -not unparseable *values*. A known key whose stored text does not decode into -its type is an error, not a warning. +not unparseable *values*. A known key whose stored text does not decode into its +type is an error, not a warning. ## The password -`web.password` is a write-only input. It is never a stored value. +`web.password` is a write-only input. It is never a stored value. A non-empty +one is hashed with argon2id (PHC encoding, OWASP argon2id parameters) into +`web.password_hash`, and the plaintext is cleared before anything is written. +There is no settings row that can hold it: the model skips `web.password` in +both directions of the settings bridge, so the plaintext has nowhere to go even +by accident. -At import time, a non-empty `web.password` is hashed with argon2id (PHC -encoding, OWASP argon2id parameters) into `web.password_hash`, and the -plaintext field is cleared before anything is written. There is no settings -row that can hold it: the model explicitly skips `web.password` in both -directions of the settings bridge, so the plaintext has nowhere to go even by -accident. `nxdns export` always writes `.password = ""` and carries the hash -instead — which is also what makes the round trip stable, since an export that -tried to reproduce a plaintext it never had could not be byte-identical. +Both fields are optional, and this is the one deliberate carve-out from +file-as-sole-truth: **a file that mentions neither leaves the stored hash +alone.** -Setting both `password` and `password_hash` in one file is an error rather -than a precedence rule. The two say different things about what the password -is, and picking a winner would mean the operator's other statement was -silently discarded. The file has to say one thing. +The reason is a trap the design walked into once. An export carries the full PHC +string, which is long and ugly, and an operator committing that file to git will +sooner or later delete the line — meaning "keep the current password". If +absence meant "no password", that edit would reconcile an empty hash over the +stored one and open the admin UI to the entire LAN, silently, because +authentication is on exactly when the hash is non-empty. Silence has to mean +keep. Disabling authentication takes the explicit `password_hash = ""`. -The practical shape of a password change is therefore export, set `.password` -to the new value, clear `.password_hash`, import with `--force`. The procedure -is in -[how-to/set-up-admin-authentication.md](../how-to/set-up-admin-authentication.md). +The other end of the same problem is `password = ""`. Hashing the empty string +produces a perfectly valid hash, so authentication would be *on* — while the +login handler refuses every empty password, so it could never be satisfied. +Auth on and unreachable is worse than either alternative, so that file is +refused at validation with a diagnostic naming the remedy. + +A plaintext password that has not changed is verified against the stored hash +and kept rather than re-hashed. That is byte-stability, not a saving: +verification recomputes the same argon2id function with the stored salt and +costs exactly what hashing costs. Hashing unconditionally would generate a fresh +salt on every start and break the invariant above. + +Export's canonical form is therefore `password = null` beside the stored +`password_hash`. Writing an empty *string* there instead would make every export +carry a present-but-empty password next to a hash — tripping the both-set rule +on re-import, so export's own output would fail export's own contract. ## What is not configuration Storage paths are process arguments, not configuration fields: `--data-dir`, -`--config`, `--web-dev`. They cannot live in the file, because the file is -found by way of them — a path that told you where to find the thing that told -you the path would be circular. They are also the settings a supervisor -(systemd, Docker) owns rather than the operator's policy about DNS. See +`--config`, `--web-dev`. They cannot live in the file, because the file is found +by way of them — a path that told you where to find the thing that told you the +path would be circular. They are also the settings a supervisor (systemd, +Docker) owns rather than the operator's policy about DNS. See [reference/files-and-directories.md](../reference/files-and-directories.md). diff --git a/docs/how-to/back-up-and-restore.md b/docs/how-to/back-up-and-restore.md index 357dff1..0a877ad 100644 --- a/docs/how-to/back-up-and-restore.md +++ b/docs/how-to/back-up-and-restore.md @@ -1,9 +1,22 @@ # Back up and restore -The configuration database is the only state worth keeping. `nxdns export` -writes it out as a ZON file and `nxdns import` writes one back. The query log is -deliberately not part of a backup: it is expendable history, and if it is -missing it gets recreated empty. +The configuration is the only state worth keeping. `nxdns export` writes it out +as a ZON file and `nxdns import` writes one back. The query log is deliberately +not part of a backup: it is expendable history, and if it is missing it gets +recreated empty. + +**Which authority the service runs under decides what the backup *is*.** Check +the start log: + +- `authority: database` — `config.db` holds the configuration. Back it up with + `nxdns export`, and restore with `nxdns import` or by replacing the database + file. +- `authority: file ()` — that file holds the configuration, and it is + already a text file you can keep in git. **The file is the backup.** Restoring + means putting the file back and restarting; the database rebuilds itself from + it. `config.db` is a cache of the file in this mode, not the thing to preserve. + +The rest of this page covers database mode unless it says otherwise. The commands below use the scratch lab from [enable DoH and DoT](enable-doh-and-dot.md), data directory @@ -40,13 +53,16 @@ grep password /tmp/nxdns-lab/backup.zon ``` ``` - .password = "", - .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$Gh+zg9xke6BqSVOiouRbqG50+Bs8ZGcXA6oKgs7lrKg$crTNMu5OI8yKBkp31r4+Y1OUQmLiAlH/qvsIxjBQRq4", + .password = null, + .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$hOjjnTrTZ6kU8XrwQuJ7ZFC3B5LumaB4hRe7kBbzJ6Q$YsC2rCTDKv96eEwPhs+D6vbDliLogEZph8EkagKSuy8", ``` -Treat backups as secrets. `.password` is always exported as `""` — the +Treat backups as secrets. `.password` is always exported as `null` — the plaintext is never stored anywhere — so the file re-imports without anyone -knowing the password. +knowing the password. `null` and `""` are different statements here: `null` +means the file says nothing about the password, while an empty `password_hash` +would disable authentication. See +[Password and hash](../reference/configuration.md#password-and-hash). Without `--out` the export goes to stdout, where the file mode is your redirect's problem: @@ -57,7 +73,7 @@ nxdns export --data-dir /tmp/nxdns-lab/data | head -10 ``` // nxdns configuration -// generated by `nxdns export` — the database is the source of truth +// generated by `nxdns export` from the running configuration .{ .upstream = .{ .attempt_timeout_ms = 2500, @@ -75,8 +91,8 @@ exports taken minutes apart differ. ## Restore onto a fresh data directory -This is the normal restore: new machine, new disk, empty data directory. No -`--force`, because there is nothing to overwrite. +This is the normal restore: new machine, new disk, empty data directory. +Nothing to overwrite, so nothing to authorise. ```sh nxdns import /tmp/nxdns-lab/backup.zon --data-dir /tmp/nxdns-lab/data-restored @@ -92,64 +108,95 @@ before writing to it. ## Restore over an existing database -`import` refuses a database that already holds configuration, so a plain -`import` can never clobber a configured server by accident: - -```sh -nxdns import /tmp/nxdns-lab/backup.zon --data-dir /tmp/nxdns-lab/data -``` - -``` -import failed: DatabaseNotEmpty -``` - -That exits 2. Say `--force` when replacing is what you mean: - -```sh -nxdns import /tmp/nxdns-lab/backup.zon --force --data-dir /tmp/nxdns-lab/data -``` - -``` -imported /tmp/nxdns-lab/backup.zon -``` - -Stop the server first. `import` replaces the whole configuration underneath a -process that has already read it, and a running server will not notice. - -What `--force` does to the client list is worth knowing before you restore an -old backup. A client the backup does not name is removed, and its first-seen -and last-seen go with it — restoring a month-old file drops the devices you -named since. Devices the server discovered from traffic are kept, and a device -the backup does name keeps the first-seen and last-seen the database already -held, so a restore does not restamp your whole network as newly arrived. - -On a real install that is the systemd unit: +**Stop the server first.** `import` rewrites configuration underneath a process +that read it at startup, and a running server picks up only part of it — +filtering follows the new rows at the next reload, while upstreams, listeners +and settings stay at their boot values until a restart. ```sh systemctl stop nxdns -nxdns import /var/backups/nxdns-config.zon --force +nxdns import /var/backups/nxdns-config.zon systemctl start nxdns ``` -**Not verified on this host.** Those three lines are the only commands on this -page that were not run: this machine has no installed nxdns systemd unit -(`systemctl status nxdns` answers `Unit nxdns.service could not be found.`) and -`systemctl stop`/`start` need root. The lab equivalent below was run, and it -exercises the same stop-import-start sequence. In the lab the server is a -foreground `nxdns run`, so stopping it is Ctrl-C in its own terminal: +`import` converges the database onto the file. Rows the file still names are +matched and updated in place; rows it no longer names are deleted. That last +part is what a restore of an old backup does to everything added since, so it +takes a flag: ```sh -nxdns export --data-dir /tmp/nxdns-lab/data --out /tmp/nxdns-lab/pre-restore.zon -# Ctrl-C the `nxdns run` terminal, or `kill` its pid from another shell -nxdns import /tmp/nxdns-lab/pre-restore.zon --force --data-dir /tmp/nxdns-lab/data -nxdns run --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon +nxdns import /tmp/nxdns-lab/old-backup.zon --data-dir /tmp/nxdns-lab/data ``` ``` -wrote /tmp/nxdns-lab/pre-restore.zon -imported /tmp/nxdns-lab/pre-restore.zon +FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it +import failed: DestructiveImport ``` +That exits 2 and rolls the transaction back, so nothing is half-applied. The +message names every table that would lose rows, which is usually enough to tell +an intended restore from the wrong file. Say `--allow-delete` when deleting is +what you mean: + +```sh +nxdns import /tmp/nxdns-lab/old-backup.zon --allow-delete --data-dir /tmp/nxdns-lab/data +``` + +``` +imported /tmp/nxdns-lab/old-backup.zon +``` + +A restore that only puts back what is already there needs no flag at all, and +neither does one that only adds rows. The flag is about deletion specifically — +including deletion in disguise: renaming a group, or correcting a typo in an +upstream URL, changes the row's identity, so the engine sees one row gone and +one arrived. + +What survives a restore is worth knowing before you take an old backup out of +the drawer. Blocklist download state is kept for every source whose URL the file +still names — checksum, counters, compiled files, and the row id they are keyed +by — so restoring does not cost a re-download. Devices the server discovered +from traffic are kept whole, and one the backup names keeps the first-seen and +last-seen the database already held, so a restore never restamps your network as +newly arrived. What does go is a client the backup does not name and that was +named by hand: that row is declarative, and it is deleted with the rest. + +> The three `systemctl` lines are the only commands on this page that were not +> run: this machine has no installed nxdns unit (`systemctl status nxdns` +> answers `Unit nxdns.service could not be found.`) and `systemctl stop`/`start` +> need root. The `nxdns import` between them is the same command the lab blocks +> above run, which were executed here as written. + +## Restoring the database file itself + +Copying `config.db` back into place works too, and it is the fastest restore on +a machine that still has one. Two rules, and the second one is where restores go +wrong: + +1. **Take the copy from a stopped instance**, or use `nxdns export` instead. A + copy taken while nxdns is running catches the main file without the changes + sitting in its write-ahead log. +2. **Delete any stale `config.db-wal` and `config.db-shm` beside the file you + restore.** SQLite silently discards a write-ahead log that does not match the + database it sits next to. It does not warn, and it does not fail — it just + answers from the main file, so a restore quietly loses its own tail. + +```sh +systemctl stop nxdns +rm -f /var/lib/nxdns/config.db-wal /var/lib/nxdns/config.db-shm +cp /var/backups/config.db /var/lib/nxdns/config.db +chown nxdns:nxdns /var/lib/nxdns/config.db +chmod 0600 /var/lib/nxdns/config.db +systemctl start nxdns +``` + +> Not verified on this host: these need root, an installed unit and +> `/var/lib/nxdns`, none of which exist here. + +None of this applies in file mode. There `config.db` is derived state — restore +the configuration file and start the service, and the first reconcile rebuilds +the database from it. + ## Verify a backup The round trip is byte-stable: exporting, importing and exporting again gives @@ -200,5 +247,6 @@ startup and before `export` and `import`; nothing walks them back, and `nxdns check` does not run them at all. Take an export before installing a new binary — see [upgrade](upgrade.md). -Every command on this page was executed on this host as written, except the -`systemctl` block marked **Not verified on this host** above. +Every `nxdns` command on this page was executed on this host as written. The +`systemctl`, `cp`, `chown` and `chmod` lines were not: they need root and an +installed unit, and both blocks holding them say so. diff --git a/docs/how-to/enable-doh-and-dot.md b/docs/how-to/enable-doh-and-dot.md index 5a798af..69f9cbd 100644 --- a/docs/how-to/enable-doh-and-dot.md +++ b/docs/how-to/enable-doh-and-dot.md @@ -62,10 +62,12 @@ to: } ``` -Write that to `/tmp/nxdns-lab/etc/config.zon`. The configuration file seeds an -empty database and is then ignored; to change these settings on a server that -already has a database, edit them through the API or through -`export`/`import` — see [the configuration model](../explanation/configuration-model.md). +Write that to `/tmp/nxdns-lab/etc/config.zon`. The lab runs +`nxdns run --config`, which makes that file the configuration: every start +reconciles the database onto it, so editing the file and restarting is how these +settings change here. A real install may instead run bare `nxdns run` and keep +the configuration in the database — see +[the configuration model](../explanation/configuration-model.md). ## 3. Check the files before starting diff --git a/docs/how-to/install-with-docker.md b/docs/how-to/install-with-docker.md index 7460f2f..15319d0 100644 --- a/docs/how-to/install-with-docker.md +++ b/docs/how-to/install-with-docker.md @@ -10,16 +10,29 @@ supported and is the last section of this page. For what each configuration field means, see [the configuration reference](../reference/configuration.md). -> Verification: the seed-file failure modes, the run and the two checks in -> step 3 were run on the machine that wrote this page, against an image built -> from this checkout rather than pulled from the registry — no release is +> Verification: the failure modes, the run and the two checks in step 3 were run +> on the machine that wrote an earlier revision of this page, against an image +> built from this checkout rather than pulled from the registry — no release is > published yet, so nothing on this page could be run against a pulled image, -> and step 1 could not be run at all. One command was run in altered form: -> host port 8080 was occupied here, so the run and the verification commands -> in step 3 were executed with the host side of the port mappings moved to -> 25353 and 28088 rather than the 53 and 8080 printed below. The container -> side was unchanged. See the note in step 3. The `chown` to uid 65532 needs -> root and was not run. +> and step 1 could not be run at all. One command was run in altered form: host +> port 8080 was occupied there, so the run and the verification commands in +> step 3 were executed with the host side of the port mappings moved to 25353 +> and 28088 rather than the 53 and 8080 printed below. The container side was +> unchanged. See the note in step 3. The `chown` to uid 65532 needs root and was +> not run. +> +> **Not re-run for the file-mode revision.** The compose file now ships +> `command: ["run", "--config=/etc/nxdns/config.zon"]`, and no container was +> started against that command on this host: staging a release image needs +> `zig build dist`, which refuses to run while `web/dist` is stale, and the web +> bundle was being rebuilt by other work in the same tree at the time. What was +> checked instead is `docker compose -f deploy/docker/compose.yaml config`, +> which resolves the file without contacting a registry and prints the `command` +> and the `:ro` bind mount as written, and the same +> `run --config=` invocation driven directly against the locally built +> binary: it printed the reconcile summary, `authority: file ()`, and +> `no changes` on the second start. The log lines quoted in step 3 come from +> that run, with the paths and ports the container uses. ## 1. Pull and verify the image @@ -65,7 +78,7 @@ does and does not prove. > `docker buildx imagetools inspect --format` shape was run here against > `alpine:3.22` on Docker Hub and printed that image's index digest. -## 2. Get the compose file and write the seed configuration +## 2. Get the compose file and write the configuration Every path on this page is relative to a checkout of the repository, because that is how it was verified. Running the published image needs no checkout, @@ -82,7 +95,7 @@ curl -fLO "$BASE/raw/tag/v$VERSION/deploy/docker/compose.yaml" > Gitea `1.27.0+dev` and returned the file with a 200. Compose bind-mounts `deploy/docker/etc-nxdns` read-only at `/etc/nxdns`. Create -it and put the seed file in it: +it and put the configuration in it: ```sh mkdir -p deploy/docker/etc-nxdns @@ -100,15 +113,25 @@ upstream: } ``` -Without that file the container exits with code 2 on a fresh volume: an empty -database has nothing to forward to. The log is -`no configuration file at '/etc/nxdns/config.zon'; using the database as it is` -followed by `nxdns run failed: NoUsableUpstreams`. +**The compose file ships file mode**, with +`command: ["run", "--config=/etc/nxdns/config.zon"]`. That file is the +configuration: the container reconciles its database onto it at every start, and +the admin interface answers 403 to configuration edits. To change anything, edit +the file and restart the container. It also means a fresh or recreated +`nxdns-data` volume rebuilds itself from the mounted file with no extra step. + +The file is therefore required, and its absence is a hard failure rather than a +start with defaults: + +``` +FAIL /etc/nxdns/config.zon: no such file +nxdns run failed: ManagedConfigUnreadable +run `nxdns check` to see the configuration in full +``` A file that is present but rejected is a different failure with the same exit code. No `default` group, no enabled upstream, a syntax error — `run` prints the -diagnostic and exits 2 as well. Both were run here against a locally built -image. A seed file whose only group was named `other`: +diagnostic and exits 2 as well. A file whose only group was named `other`: ``` FAIL groups: no group named 'default'; every unknown client is assigned to it @@ -116,18 +139,31 @@ nxdns run failed: MissingDefaultGroup run `nxdns check` to see the configuration in full ``` -and an empty `/etc/nxdns`: +Under `restart: unless-stopped` any of these is a restart loop — Docker has no +start limit and will retry forever. Read the lines above the failure, which name +the fault. See [Troubleshoot nxdns](troubleshoot.md). -``` -info(config_bootstrap): no configuration file at '/etc/nxdns/config.zon'; using the database as it is -nxdns run failed: NoUsableUpstreams -run `nxdns check` to see the configuration in full +### Database mode in Docker instead + +Drop the `command:` line from `compose.yaml` and the container runs +`nxdns run`, with the database as the configuration and the file read by nothing. +On a fresh volume that database is empty and the container exits 2 with +`NoUsableUpstreams`, so load it once before bringing the service up: + +```sh +docker compose -f deploy/docker/compose.yaml run --rm nxdns import /etc/nxdns/config.zon ``` -Under `restart: unless-stopped` either one is a restart loop, and the exit code -alone no longer tells them apart: read the lines above the failure, which either -name the diagnostic in the file or say there was no file at all. See -[Troubleshoot nxdns](troubleshoot.md). +The file is positional; add `--allow-delete` when re-running it against a +populated volume and the diff deletes rows. Without this step, `restart: +unless-stopped` plus exit 2 is a crash loop with no way out. + +> Not run in a container on this host, for the reason in the verification note +> at the top: no image could be staged here. The `nxdns import ` and +> `nxdns import --allow-delete` commands inside it were run directly +> against the locally built binary — the first applied an additive file and +> exited 0, the second was required after a plain `import` refused a +> row-deleting file with `DestructiveImport` and exited 2. The container runs as uid 65532, and the mount is read-only, so the container cannot repair permissions itself. Mode 0644 works and was used here. If the @@ -140,9 +176,12 @@ chmod 0600 deploy/docker/etc-nxdns/config.zon ``` > Not verified on this host: `chown` to a uid you do not own needs root. What -> was verified is the failure it prevents — a seed file at 0600 owned by -> another uid makes the container log `nxdns run failed: AccessDenied` and -> restart in a loop. See [Troubleshoot nxdns](troubleshoot.md). +> was verified is the failure it prevents — a configuration file at 0600 owned +> by another uid makes the container refuse to start and restart in a loop. In +> file mode an unreadable file is a configuration fault: +> `FAIL /etc/nxdns/config.zon: not readable` followed by +> `nxdns run failed: ManagedConfigUnreadable`, exit 2. See +> [Troubleshoot nxdns](troubleshoot.md). ## 3. Run it @@ -169,14 +208,21 @@ bind mount — against the directory holding the file, not against your shell, and it takes the project name `docker` from that directory either way, which is why the container is `docker-nxdns-1`. -A healthy first start logs the seeding and the bound sockets: +A healthy first start logs the reconcile, the authority and the bound sockets: ``` -info(config_bootstrap): seeded the database from '/etc/nxdns/config.zon' +info(migrations): config.db migrated from schema version 0 to 2 +reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +45 ~0 -0; +settings keys changed: dns.bind_ipv4 dns.bind_ipv6 dns.port web.bind web.port … +web authentication is now enabled +info(nxdns): authority: file (/etc/nxdns/config.zon) info(nxdns): nxdns serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53; 1 upstream(s); blocklist generation 1 info(web_server): web interface listening on 0.0.0.0:8080 ``` +Every later start on an unchanged file reports `reconciled +'/etc/nxdns/config.zon': no changes` and writes nothing to the database. + Confirm it answers and that the admin interface is up: ```sh diff --git a/docs/how-to/install-with-systemd.md b/docs/how-to/install-with-systemd.md index f98e16d..084a4e3 100644 --- a/docs/how-to/install-with-systemd.md +++ b/docs/how-to/install-with-systemd.md @@ -154,11 +154,11 @@ in step 5, and step 4 has to write a file into it before then. systemd does not mind finding the directory already there; it adjusts the mode and ownership to what the unit asks for. -## 4. Write the seed configuration +## 4. Write the configuration -nxdns starts from an empty database only if a configuration file tells it what -to forward to. Write `/etc/nxdns/config.zon`. The smallest file that starts is -one group named `default` and one enabled upstream: +nxdns will not start with nothing to forward to. Write `/etc/nxdns/config.zon`. +The smallest file that starts is one group named `default` and one enabled +upstream: ```zon .{ @@ -213,36 +213,49 @@ The upstream probe sends a real query, so this needs working DNS on the host at the time you run it. Exit 2 means `check` found something to fix and printed every problem it found, not only the first. -The file seeds the database once. From the second start onwards it is ignored -and the database is the configuration; see -[the configuration model](../explanation/configuration-model.md) and -[Upgrade nxdns](upgrade.md) for how to change settings after that. +Now load it into the database: -Once the seed has been consumed — after step 6 confirms you can log in — the -plaintext in it is dead weight that only carries risk. The seed's -`web.password` is hashed into `web.password_hash` at import time and the -plaintext is never stored; `nxdns export` writes `.password = ""` back out -alongside the hash. Nothing downstream ever reads the plaintext again, so -delete the file: +```sh +nxdns import /etc/nxdns/config.zon +``` + +``` +info(migrations): config.db migrated from schema version 0 to 2 +imported /etc/nxdns/config.zon +``` + +The plaintext password is hashed into `web.password_hash` and never stored as +plaintext; `nxdns export` writes `.password = null` beside the hash. Nothing +downstream reads the plaintext again, so once step 6 confirms you can log in you +can delete the file: ```sh rm /etc/nxdns/config.zon ``` -Keep it only if you want the seed as a record of the intended starting -configuration, and if you keep it, leave it at 0640 root:nxdns. Note that a -kept seed is not a backup — `nxdns export` is -(see [Back up and restore](back-up-and-restore.md)), and the export carries the -password hash rather than the password. +A kept file is not a backup — `nxdns export` is (see +[Back up and restore](back-up-and-restore.md)), and the export carries the +password hash rather than the password. If you keep it, leave it at 0640 +root:nxdns. + +That is the **database mode** install, which is what the packaged unit runs: +`ExecStart=/usr/local/bin/nxdns run`, no `--config`, so nothing reads a file +after this step. Change settings afterwards through the admin interface, the +API, or an export–edit–import cycle. + +If you would rather keep `/etc/nxdns/config.zon` in git and have every restart +converge onto it, do not delete the file — go to +[Run in file mode](#run-in-file-mode) instead, and skip the `rm`. > Verified on this host, with a scratch `--config` and `--data-dir` in place of -> `/etc/nxdns` and `/var/lib/nxdns`: a seed written under umask 022 came out -> 0644, `nxdns check --config` on it printed `OK: no problems found` with no -> mode warning, and after `nxdns import` of that seed an `nxdns export` wrote -> `.password = ""` next to a populated `.password_hash = -> "$argon2id$v=19$..."`. The `chown`, `chmod` and `rm` lines above are the -> ordinary root-owned-file operations and were not run against a real -> `/etc/nxdns`, which this host does not have. +> `/etc/nxdns` and `/var/lib/nxdns` — those two paths are the only difference +> from the blocks above. A file written under umask 022 came out 0644, +> `nxdns check --config` on it printed `OK: no problems found` with no mode +> warning, `nxdns import` of it printed the migration line and `imported ` +> and exited 0, and a following `nxdns export` wrote `.password = null` next to +> a populated `.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…"`. The +> `chown`, `chmod` and `rm` lines are ordinary root-owned-file operations and +> were not run against a real `/etc/nxdns`, which this host does not have. ## 5. Start it @@ -263,9 +276,16 @@ nxdns writes to stderr and systemd captures that into the journal; logging needs no further configuration. Port 53 is privileged, and the unit grants `CAP_NET_BIND_SERVICE` through `AmbientCapabilities`. +The unit does not restart the service after exit 2 or exit 64 +(`RestartPreventExitStatus=2 64`). Those are a wrong configuration and a wrong +command line, and neither clears on a retry — restarting every two seconds until +`StartLimitBurst` gives up would only bury the diagnostics that are already in +the journal. `systemctl status nxdns` shows the failed state; fix the cause and +start it again. + If the start fails, read [Troubleshoot nxdns](troubleshoot.md). The two common first-install failures are a port 53 already held by `systemd-resolved` and a -seed file that does not parse. +configuration file that does not parse. ## 6. Confirm it answers @@ -276,7 +296,7 @@ dig @ example.com A +short ``` The admin interface is on port 8080 by default; log in with the password from -the seed file. `http://:8080/api/health` reports upstream +the configuration file. `http://:8080/api/health` reports upstream availability and disk state without a login. > Not verified on this host as written: `` is a placeholder, and a @@ -287,6 +307,140 @@ availability and disk state without a login. > port returned 200. Only the address and the port differ from the lines > above. +## Run in file mode + +In file mode `/etc/nxdns/config.zon` is the configuration: every start converges +the database onto it, and the admin interface refuses configuration edits with a +403 naming the file. Use it when you want the file in git and deployed by +Ansible. Stay in database mode when you want the UI to be the way things change. + +The packaged unit is flagless on purpose — it is correct as shipped, and a +commented-out alternative `ExecStart` in a unit file is documentation +masquerading as configuration. File mode is a drop-in. + +### Adopt file mode on a box that is already running + +Run these in order. **Stop first**, and do not skip that: any edit made through +the UI between an export and the restart would be silently reverted by the first +reconcile, and `nxdns check` against a live database refuses to grade it (below). + +If you are arriving here from an upgrade, the binary must already be the new +one before you export. An export written by 0.0.1 carries a `.password = ""` +line this binary refuses; see +[the order trap](upgrade.md#the-order-trap-export-with-the-new-binary-not-the-old-one). + +```sh +systemctl stop nxdns +nxdns export --out /etc/nxdns/config.zon +nxdns check --config /etc/nxdns/config.zon +``` + +``` +wrote /etc/nxdns/config.zon +checking configuration file /etc/nxdns/config.zon +OK upstreams[0] https://cloudflare-dns.com +OK: no problems found +``` + +Then add the drop-in and start: + +```sh +mkdir -p /etc/systemd/system/nxdns.service.d +cat > /etc/systemd/system/nxdns.service.d/file-mode.conf <<'EOF' +[Service] +ExecStart= +ExecStart=/usr/local/bin/nxdns run --config=/etc/nxdns/config.zon +EOF +systemctl daemon-reload +systemctl start nxdns +``` + +The empty `ExecStart=` is required. Without it systemd appends a second command +to the list rather than replacing the first, and the unit tries to run nxdns +twice. + +The first start after adoption changes nothing, because the file was rendered +from the database it is now governing: + +``` +reconciled '/etc/nxdns/config.zon': no changes +info(nxdns): authority: file (/etc/nxdns/config.zon) +info(nxdns): nxdns 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 '': no changes` and +> `authority: file ()`. A second file-mode start printed `no changes` +> again and loaded the 3096006-byte compiled blocklist from disk with no +> download. Dropping the flag printed `authority: database` and served the same +> configuration. +> +> The `systemctl`, `mkdir`, `cat > …/file-mode.conf` and `rm` lines need root and +> an installed unit and were **not** run. What was checked instead: +> `systemd-analyze verify` on `deploy/systemd/nxdns.service` with those exact two +> `ExecStart` lines appended, which reported only the usual +> `Command /usr/local/bin/nxdns is not executable` for the absent binary and +> nothing about the override. + ## Raspberry Pi 5 The Pi 5 is aarch64. Nothing about the procedure changes except which tarball diff --git a/docs/how-to/set-up-admin-authentication.md b/docs/how-to/set-up-admin-authentication.md index 1ddd837..6203c24 100644 --- a/docs/how-to/set-up-admin-authentication.md +++ b/docs/how-to/set-up-admin-authentication.md @@ -11,7 +11,7 @@ data directory is `/var/lib/nxdns` and the web port is 8080. ## 1. Set the password -Put it in the seed configuration file, under `web`: +Put it in the configuration file, under `web`: ```zon .{ @@ -21,17 +21,60 @@ Put it in the seed configuration file, under `web`: } ``` -At import time the plaintext is hashed with argon2id into `web.password_hash` -and discarded. It becomes no database row and appears in no log line. Setting -both `password` and `password_hash` in one file is refused: +The plaintext is hashed with argon2id into `web.password_hash` and discarded. It +becomes no database row and appears in no log line. Setting both `password` and +`password_hash` in one file is refused: ``` web.password: password and password_hash are both set; ambiguity in a security setting is refused import failed: PasswordAndHashBothSet ``` -The seed file is read only while the database is empty. On a server that -already has a database, use step 4 or step 5 instead. +Applying that file — with `nxdns import`, or with a `nxdns run --config` start — +announces the change: + +``` +web authentication is now enabled +``` + +### Absent, empty, and set are three different things + +The two fields are optional, and the difference between leaving one out and +setting it to `""` is the difference between keeping your password and removing +it: + +| The file says | Effect on the stored password | +| --- | --- | +| Neither field | Nothing. It stays exactly as it was. | +| `.password = "…"` | Installs that password. Unchanged plaintext keeps the existing hash rather than re-hashing it. | +| `.password = ""` | Refused. | +| `.password_hash = "$argon2id$…"` | Installs that hash, for example from an export. | +| `.password_hash = ""` | **Removes the password.** Authentication is off. | + +Absence has to mean "keep", because the alternative is a foot-gun with a live +round in it. An export carries the full PHC string, which is long and ugly, and +sooner or later someone trims that line out of a file before committing it — +meaning "leave the password alone". If absence meant "no password", that edit +would open the admin interface to the whole LAN without a word. + +So removing the password takes the explicit empty string: + +``` +web authentication is now disabled +``` + +And an empty plaintext is refused outright, because hashing the empty string +would switch authentication *on* while making every login impossible — the login +handler rejects empty passwords: + +``` +FAIL web.password: password is set to the empty string; omit the field to keep the stored password, or set password_hash = "" to disable authentication +``` + +Which of steps 4 and 5 applies to your server depends on its authority. Under +`nxdns run --config FILE` the file is the password: edit it and restart, and the +API refuses the change with a 403. Under bare `nxdns run` the database holds it, +and step 4 or step 5 is how it moves. ## 2. Log in @@ -63,7 +106,7 @@ The session token comes back in a `Set-Cookie` header, not in the body. In the jar it looks like this (value redacted here): ``` -#HttpOnly_127.0.0.1 FALSE / FALSE 1785770178 nxdns_session +#HttpOnly_127.0.0.1 FALSE / FALSE 1786559938 nxdns_session ``` The cookie is named `nxdns_session` and carries `HttpOnly; SameSite=Lax; @@ -123,6 +166,9 @@ already is. ## 4. Change the password on a running server +This is a database-mode procedure. In file mode `PUT /api/settings` answers 403 +naming the file; edit `web.password` there and restart instead. + Send the new one to `PUT /api/settings` as `web.password`. The response is the full settings document; `password` is write-only and `password_hash` is neither readable nor directly writable, so neither value comes back. @@ -161,7 +207,7 @@ Log back in with the new password. That is the whole rotation. If you have lost the password, the admin interface cannot help — go through the database instead. Export, edit, import. `nxdns export` always writes -`.password = ""` and carries the hash, so an exported file re-imports without +`.password = null` and carries the hash, so an exported file re-imports without anyone knowing the password. To install a new one, put it in `.password` and clear `.password_hash`: @@ -169,11 +215,22 @@ clear `.password_hash`: nxdns export --data-dir /tmp/nxdns-lab/data --out /tmp/nxdns-lab/rekeyed.zon ``` -Edit the `web` section of `/tmp/nxdns-lab/rekeyed.zon` so it reads: +Edit the `web` section of `/tmp/nxdns-lab/rekeyed.zon`: set `.password` to the +new value and **delete the `.password_hash` line entirely**, so the `web` block +carries one password field and not two: ```zon .password = "offline-password", - .password_hash = "", +``` + +Deleting the line is the part to get right. Setting `.password_hash = ""` +alongside a plaintext password does not clear the way for it — an empty string +is a present value meaning "no password", so the file then states two +contradictory things and is refused: + +``` +FAIL web.password: password and password_hash are both set; ambiguity in a security setting is refused +import failed: PasswordAndHashBothSet ``` Stop the server before importing. `import` rewrites the stored hash underneath a @@ -184,14 +241,17 @@ terminal stops it, and it goes back up with the same command: ```sh # Ctrl-C the `nxdns run` terminal, or `kill` its pid from another shell -nxdns import /tmp/nxdns-lab/rekeyed.zon --force --data-dir /tmp/nxdns-lab/data -nxdns run --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon +nxdns import /tmp/nxdns-lab/rekeyed.zon --data-dir /tmp/nxdns-lab/data +nxdns run --data-dir /tmp/nxdns-lab/data ``` ``` imported /tmp/nxdns-lab/rekeyed.zon ``` +No flag is needed: replacing a password edits a settings value and deletes no +rows. + On a real install the stop and start are `systemctl stop nxdns` and `systemctl start nxdns` around the same `import` — **not verified on this host**, which has no installed nxdns systemd unit (`systemctl status nxdns` @@ -202,7 +262,7 @@ Once it is back up the old password is refused and the new one works: ```sh curl -sS -X POST http://127.0.0.1:8451/api/auth/login \ - -H 'content-type: application/json' -d '{"password":"a-new-password"}' \ + -H 'content-type: application/json' -d '{"password":"lab-password"}' \ -w ' (old password, http %{http_code})\n' curl -sS -c /tmp/nxdns-lab/c5.txt -X POST http://127.0.0.1:8451/api/auth/login \ -H 'content-type: application/json' -d '{"password":"offline-password"}' \ @@ -217,19 +277,19 @@ curl -sS -b /tmp/nxdns-lab/c5.txt -o /dev/null -w 'stats: %{http_code}\n' \ stats: 200 ``` -The next export shows the new hash and an empty `password` again: +The next export shows the new hash and a null `password` again: ```sh nxdns export --data-dir /tmp/nxdns-lab/data | grep password ``` ``` - .password = "", - .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$kvlRj1tdGul3MlfbvzLncLKWirNpJRJ3howFA9/ysgg$7elW7PPQ3WXHwI4YOmOpZ/1KNEQo7ZDLRJhnYOPMjqw", + .password = null, + .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$xqzK66LgiWGvyCmCl6ZRa3GHH0nS5qZnRgVfWmeGadc$1mafhflKFIg3vcHjJaDMAXGQiOjtym2UADZsPW1xkfw", ``` -`--force` is required because the database already holds configuration. See -[back up and restore](back-up-and-restore.md). +See [back up and restore](back-up-and-restore.md) for when `import` does need +`--allow-delete`. ## What happens with no password set @@ -279,6 +339,11 @@ interface at the very least, and preferably set a password. [configuration reference](../reference/configuration.md); the routes are in the [API reference](../reference/api.md). -Every command on this page was executed on this host as written, except the -`systemctl` stop and start named in step 5 and marked **not verified on this -host** there. +Every command on this page was executed on this host as written, against the +lab described at the top, except the `systemctl` stop and start named in step 5 +and marked **not verified on this host** there. That includes the whole of +steps 2 to 5, re-run for this revision: the login, logout and rate-limit +transcripts reproduced exactly as printed, the both-set refusal in step 5 was +reproduced by leaving `.password_hash = ""` in the file, and the rekey then +succeeded once that line was deleted. The cookie jar's expiry timestamp is the +one that run produced and will differ on yours. diff --git a/docs/how-to/troubleshoot.md b/docs/how-to/troubleshoot.md index d25ae07..e44342d 100644 --- a/docs/how-to/troubleshoot.md +++ b/docs/how-to/troubleshoot.md @@ -37,10 +37,22 @@ checked on its first line. **Fixes by cause.** - `NoUsableUpstreams` — the database has no enabled upstream. On a fresh - install this means the seed file was missing or in the wrong place; the start - log says `no configuration file at '/etc/nxdns/config.zon'; using the - database as it is`. Write the seed file and start again against the still - empty database, or `nxdns import --force`. + install in database mode this is simply an empty database, and the run says + what to do about it on the next line: + + ``` + nxdns run failed: NoUsableUpstreams + run `nxdns check` to see the configuration in full + load one with `nxdns import `, or make a file the source of truth with `nxdns run --config ` + ``` + + Write a configuration file and take either exit: `nxdns import ` to load + it into the database once, or add `--config ` to `ExecStart` to make the + file the configuration from then on. +- `ManagedConfigUnreadable` — the service runs `run --config FILE` and that file + is missing or the process may not read it. The path is in the FAIL line above + the failure. File mode never falls back to the database, on purpose: a + fallback would turn a bad deploy into a silently stale configuration. - `BadCertificate` — a DoH or DoT listener is enabled and its certificate or key is unreadable, too large, unparseable, or the key does not belong to the certificate. `run` names both paths before it exits: @@ -79,10 +91,10 @@ checked on its first line. - `BadBindAddress` — `dns.bind_ipv4` or `dns.bind_ipv6` is not an address of that family. -## A seed file you just wrote is rejected +## A configuration file you just wrote is rejected -**Symptom.** A first start against an empty database prints the validation -problem and stops with exit 2: +**Symptom.** `nxdns run --config`, `nxdns check --config` or `nxdns import` +prints the validation problem and stops with exit 2: ``` FAIL groups: no group named 'default'; every unknown client is assigned to it @@ -98,7 +110,7 @@ nxdns run failed: ParseZon run `nxdns check` to see the configuration in full ``` -So does a seed file whose upstream list is empty or all disabled: +So does a file whose upstream list is empty or all disabled: ``` FAIL upstreams: at least one upstream must be enabled @@ -106,9 +118,9 @@ nxdns run failed: NoUpstreams run `nxdns check` to see the configuration in full ``` -`NoUpstreams` from a seed file is not the same fault as `NoUsableUpstreams` -above: the first is a file `run` refused, the second is a database `run` -accepted and found empty. Both are exit 2. +`NoUpstreams` from a file is not the same fault as `NoUsableUpstreams` above: +the first is a file `run` refused, the second is a database `run` accepted and +found empty. Both are exit 2. **Diagnosis.** Run the same file through `check`, which reports the same problems and exits 2: @@ -117,11 +129,21 @@ problems and exits 2: nxdns check --config /etc/nxdns/config.zon ``` -**Fix.** Correct the file the diagnostics name and start again. The database is -still empty after a failed seed, so the next start re-reads the file. The exit -code no longer depends on which command read the file: all three of these files -were run through `run`, `check` and `import` here, and every one of the nine -combinations exited 2 with the same diagnostic. +**Fix.** Correct the file the diagnostics name and start again. Nothing was +applied — a file-mode reconcile happens in one transaction that rolls back, and +a failed `import` leaves the database untouched. The exit code does not depend +on which command read the file: all three of these files were run through `run`, +`check` and `import` here, and every one of the nine combinations exited 2 with +the same diagnostic. + +Under the shipped systemd unit an exit 2 stops the service rather than +restarting it (`RestartPreventExitStatus=2 64`), so the journal holds the +diagnostics instead of drowning them in a restart loop. `systemctl start nxdns` +once the file is fixed. + +Make `nxdns check --config ` the precondition in whatever pushes the file. +In file mode every boot reads it, so an unvalidated bad push does not fail at +deploy time — it fails at the next restart, which may be a power cut at 3am. ## `nxdns check` fails on a server that is running fine @@ -210,10 +232,11 @@ startup cycle, not a fix. ## The container restarts in a loop **Symptom.** `docker compose ps` shows the container restarting, and the log is -one line repeated: +the same failure repeated. Docker has no start limit, so this goes on forever. ``` -nxdns run failed: AccessDenied +FAIL /etc/nxdns/config.zon: not readable +nxdns run failed: ManagedConfigUnreadable ``` **Diagnosis.** @@ -223,10 +246,10 @@ docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}} restarts={{.Restar stat -c '%a %u:%g %n' deploy/docker/etc-nxdns/config.zon ``` -Exit 1 with `AccessDenied` means the container could not read the seed file. -The container runs as uid 65532 and `/etc/nxdns` is mounted read-only, so a -file at mode 0600 owned by your own uid is unreadable to it and the container -cannot repair it. +Exit 2 naming the configuration path means the container could not read the +file the shipped `command:` makes its configuration. The container runs as uid +65532 and `/etc/nxdns` is mounted read-only, so a file at mode 0600 owned by +your own uid is unreadable to it and the container cannot repair it. **Fix.** Either make the file world-readable, when it holds no secret: @@ -241,14 +264,65 @@ chown 65532:65532 deploy/docker/etc-nxdns/config.zon chmod 0600 deploy/docker/etc-nxdns/config.zon ``` -The 0644 path was verified here, including the recovery: after the `chmod` the -container started and answered queries. The `chown` needs root and was not run -here. +The 0644 path was verified against an earlier revision of this page, including +the recovery: after the `chmod` the container started and answered queries. The +`chown` needs root and was not run here. -A container that exits 2 instead — `nxdns run failed: NoUsableUpstreams` after -`no configuration file at '/etc/nxdns/config.zon'` — has no seed file at all on -a fresh volume. Create `deploy/docker/etc-nxdns/config.zon` and bring it up -again; see [Install with Docker](install-with-docker.md). +`FAIL /etc/nxdns/config.zon: no such file` instead of `not readable` means there +is no configuration file at all. Create `deploy/docker/etc-nxdns/config.zon` and +bring it up again; see [Install with Docker](install-with-docker.md). + +A container that exits 2 with `NoUsableUpstreams` is in database mode — the +`command:` line naming `--config` was removed — on a volume whose database is +still empty. Load one and bring it back up: + +```sh +docker compose -f deploy/docker/compose.yaml run --rm nxdns import /etc/nxdns/config.zon +``` + +> Not re-run on this host: staging a release image needs `zig build dist`, which +> could not run here while the web bundle was mid-rebuild by other work in the +> same checkout. The failure text quoted above is what the same binary prints +> outside a container, which was reproduced here, with the container's paths. + +## The admin interface refuses an edit with 403 + +**Symptom.** Saving anything in the admin interface fails, and the API answers: + +```json +{"error":"configuration is managed by /etc/nxdns/config.zon; edit the file and restart"} +``` + +This is not a fault. The service runs `nxdns run --config`, which makes that +file the configuration, and configuration writes through the API are refused so +the file and the running server cannot drift apart. + +**Diagnosis.** The start log names the authority: + +```sh +journalctl -u nxdns | grep 'authority:' +``` + +``` +info(nxdns): authority: file (/etc/nxdns/config.zon) +``` + +**Fix.** Edit the file, validate it, restart: + +```sh +$EDITOR /etc/nxdns/config.zon +nxdns check --config /etc/nxdns/config.zon +systemctl restart nxdns +``` + +Or, if you want the interface to be how this box is configured, leave file mode: +drop `--config` from `ExecStart` and restart. The database already holds the +last reconciled state, so nothing is lost. See +[Run in file mode](install-with-systemd.md#run-in-file-mode). + +Pausing blocking, refreshing blocklists and reloading certificates are not +configuration and keep working in file mode. Deleting a client works too, unless +the file names that client's address. ## The container cannot reach its upstreams @@ -324,7 +398,8 @@ window of unfiltered answers. A generation number with nothing being blocked is a different problem: the snapshot loaded but has no sources in it. The line `blocklist snapshot generation 1: 0 of 0 sources loaded` says exactly that. Add -a source in the admin interface, or in the seed file before the first start. +a source in the admin interface, or a `blocklist_sources` entry to the +configuration file with a `group_sources` link naming a group. ## A database stamped by a newer binary diff --git a/docs/how-to/upgrade.md b/docs/how-to/upgrade.md index 4044903..73cc3e2 100644 --- a/docs/how-to/upgrade.md +++ b/docs/how-to/upgrade.md @@ -21,6 +21,105 @@ Upgrading a build you made yourself is the last section of this page. > shapes and the verification commands are covered by > [Verify a release](verify-a-release.md), which says what was probed and how. +## Breaking change: `run --config` now means file authority + +**Read this before upgrading if anything on your box passes `--config` to +`nxdns run`** — a systemd drop-in, a wrapper script, or a `command:` in a +compose file. + +`run --config FILE` used to mean *seed once*: the file was read only while the +database was still empty, and ignored on every start after that. It now means +*the file is the configuration*: every start reconciles the database onto it. + +For a box that was seeded once and then configured through the admin interface, +the first start after the upgrade converges the database back to that old seed +file. **Every change made through the UI since seeding is deleted.** + +There are two ways out, and you pick before you restart: + +- **Keep the database.** Drop the flag. `nxdns run` with no `--config` serves + the database exactly as it did before, and nothing reads a file. This is the + right answer if the UI is how you change things. +- **Adopt file mode cleanly.** Install the new binary, stop the service, export + the current database over the file path, check it, then start with the flag. + The first reconcile is then a no-op, because the file was rendered from the + database it governs. **Install the new binary first** — see the order trap + below. The full procedure is + [Adopt file mode](install-with-systemd.md#adopt-file-mode-on-a-box-that-is-already-running). + +`nxdns check --config FILE` is unchanged: it graded that file before and it +grades that file now. + +### The order trap: export with the new binary, not the old one + +Take the export **after** you have replaced the binary, with the service +stopped. Exporting first — the instinctive order, and the one step 1 of this +page tells you to take for a backup — produces a file the new binary refuses. + +A 0.0.1 `nxdns export` writes both fields: + +```zon + .password = "", + .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…", +``` + +An empty `password_hash` used to mean "unset". It now means "disable +authentication", so it is a *present* value — and a file that carries both +fields states two different things about the password and is refused: + +``` +FAIL web.password: password and password_hash are both set; ambiguity in a security setting is refused +FAIL web.password: password is set to the empty string; omit the field to keep the stored password, or set password_hash = "" to disable authentication +nxdns run failed: PasswordAndHashBothSet +``` + +The old `nxdns check` passes that file, because the old binary agreed with the +old rule. So the failure lands at the first start after the upgrade, with the +resolver stopped and the unit refusing to retry it +(`RestartPreventExitStatus=2 64`). A new `nxdns export` writes +`.password = null` instead and has no such problem. + +**If you already have an old export you want to adopt**, you do not need to +redo it. Delete the empty-password line and the file is valid: + +```sh +sed -i '/^ \.password = "",$/d' /etc/nxdns/config.zon +nxdns check --config /etc/nxdns/config.zon +``` + +Keep the `.password_hash` line — that is the password, and deleting it as well +would leave the file saying nothing about authentication, which means "keep +whatever is stored" rather than anything you would notice. + +The same trap has nothing to do with file mode as such: it is any 0.0.1 export +fed to the new binary, so it also applies to a restore through `nxdns import`. +Backups taken with 0.0.1 need that one line removed before they will load. + +> Verified on this host, with one substitution stated: the repository has no +> 0.0.1 binary to hand, so the old export was **simulated** by taking a current +> `nxdns export` and rewriting `.password = null` to `.password = ""`, which is +> the one byte-level difference between the two formats. Against that file, +> `nxdns check --config` printed both FAIL lines above and exited 2, and +> `nxdns run --config` printed them and failed `PasswordAndHashBothSet`. After +> the `sed` above, `nxdns check --config` exited 0 with `OK: no problems found` +> and `nxdns import` of the same file exited 0, keeping the hash. The claim +> about what 0.0.1's `export` emitted is read from that release's source — +> `git show v0.0.1:src/config/export.zig` line 71 is `cfg.web.password = "";` — +> not from running that binary. + +A database-mode install that never passed `--config` needs nothing. Under +Docker, a fresh database-mode install must either take the new compose file or +run `import` once — see +[Database mode in Docker](install-with-docker.md#database-mode-in-docker-instead). + +Two smaller renames in the same release: `nxdns import --force` is now +`--allow-delete`, and it is required only when the file's diff would delete +rows rather than whenever the database is non-empty. `nxdns check` no longer +falls back to a default file path when there is no database; it reports the +absent database and names the two ways to get one. + +There is no schema migration in this change. + ## 1. Take an export first There is no downgrade path, so the export is what you fall back to: @@ -33,6 +132,13 @@ nxdns export --out /some/backup/nxdns-config.zon command relies on the default `--data-dir /var/lib/nxdns` that a systemd install has. +This export is a fallback, not a file to deploy. If you are adopting file mode, +take a *second* export after the binary swap and use that one — an export +written by 0.0.1 carries a `.password = ""` line the new binary refuses, as +[the order trap](#the-order-trap-export-with-the-new-binary-not-the-old-one) +explains. The same line has to come out of this backup before the new binary +will import it. + > Verified on this host with both paths substituted, since it has neither > `/var/lib/nxdns` nor `/some/backup`. `SCRATCH` below is a scratch directory, > and its `data/` was populated beforehand with `nxdns import`: @@ -131,9 +237,11 @@ NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml pull NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml up -d ``` -Compose recreates the container against the same `nxdns-data` volume. The seed -file in `etc-nxdns` is not read again; the database in the volume is the -configuration. +Compose recreates the container against the same `nxdns-data` volume. What +happens to the file in `etc-nxdns` depends on the `command:` in your compose +file: with the shipped `run --config=/etc/nxdns/config.zon` the file is the +configuration and the restart reconciles onto it; without it, the database in +the volume is the configuration and the file is read by nothing. Set `NXDNS_VERSION` on both lines, or export it. Without it the compose file falls back to `:latest`, and `pull` and `up` could then land on different @@ -184,11 +292,12 @@ OK: no problems found > zig 0.16.0 > ``` > -> Against a running server whose database had just been migrated and seeded, -> `nxdns check --data-dir` printed the uncheckpointed-log line above and exited -> 2, while `nxdns export --out` followed by `nxdns check --config` on the result -> exited 0 with `OK: no problems found`. The `dig` line was not run in this -> round: nothing is listening on 127.0.0.1:53 here, and port 53 needs root. +> Against a running server whose database had just taken a configuration write +> through the API, `nxdns check --data-dir` printed the uncheckpointed-log line +> above and exited 2, while `nxdns export --out` followed by +> `nxdns check --config` on the result exited 0 with `OK: no problems found`. +> Both were re-run for this revision. The `dig` line was not run in this round: +> nothing is listening on 127.0.0.1:53 here, and port 53 needs root. ## What happens to the database @@ -235,46 +344,89 @@ nxdns run failed: SchemaTooNew That run exits 1. Recovering means importing the export you took in step 1 into a fresh data directory with the older binary. +### Rolling back from file mode + +Putting an older binary back needs no unit edit. The old binary accepts +`run --config` — it just reads it as the old seed-once flag — and against a +database that already holds configuration it ignores the file entirely and +serves the last state the new binary reconciled. So the service comes back up +on the configuration it was running. + +The consequence is worth stating plainly: **file edits stop applying.** The old +binary will not re-read the file, so every change made to `config.zon` after the +rollback does nothing at all, silently, until the newer binary is back. If you +have to stay on the old binary, use `nxdns import` to apply file changes, or drop +the flag so the invocation matches what the binary actually does. + +The schema note above still governs: a database stamped by a newer binary +refuses to open, whatever mode either binary runs in. + ## Changing settings, not the binary -An upgrade never re-reads `/etc/nxdns/config.zon`. After the first successful -seed the file is ignored, and the start log says so: +How you change a setting depends on which authority the service runs under. +`nxdns run` in `ExecStart` means the database; `nxdns run --config FILE` means +the file. The start log names it either way: ``` -info(config_bootstrap): configuration file ignored; the database is already configured +info(nxdns): authority: database +info(nxdns): authority: file (/etc/nxdns/config.zon) ``` -Change settings through the admin interface, through the API, or with an -export–edit–import cycle against a stopped server: +**In file mode**, edit the file, validate it, restart. The admin interface will +refuse the change with a 403 naming the file, so there is nothing to get wrong: + +```sh +$EDITOR /etc/nxdns/config.zon +nxdns check --config /etc/nxdns/config.zon +systemctl restart nxdns +``` + +**In database mode**, change settings through the admin interface, through the +API, or with an export–edit–import cycle against a stopped server: ```sh nxdns export --out config-backup.zon $EDITOR config-backup.zon systemctl stop nxdns -nxdns import config-backup.zon --force +nxdns import config-backup.zon systemctl start nxdns ``` -`--force` is required here. A plain `import` into a database that already holds -configuration fails with `import failed: DatabaseNotEmpty` and exits 2, so it -cannot clobber a configured server by accident. What counts is what an operator -set: client rows the DNS path materialised from traffic never trigger the -refusal on their own. +`import` needs no flag to add rows or to edit them. It needs `--allow-delete` +only when applying the file would delete rows the database holds — including the +case where you renamed something, since changing a group's name or an upstream's +URL is a delete and an insert to the engine, not an edit. The refusal names the +tables and rolls back: -> Verified on this host for the two `nxdns` lines, against a populated scratch -> data directory: +``` +FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it +import failed: DestructiveImport +``` + +Stop the server first either way. `import` rewrites configuration underneath a +process that read it at startup, and a running server picks up only some of it. + +> Verified on this host against a populated scratch data directory, with +> `--data-dir` pointing at it — that path is the only difference from the blocks +> above: > > ``` -> $ nxdns import $SCRATCH/nxdns-config.zon --data-dir $SCRATCH/data -> import failed: DatabaseNotEmpty +> $ nxdns import $SCRATCH/etc/config.zon --data-dir $SCRATCH/dbmode +> imported /…/config.zon +> (exit 0) +> $ nxdns import $SCRATCH/etc/smaller.zon --data-dir $SCRATCH/dbmode +> FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it +> import failed: DestructiveImport > (exit 2) -> $ nxdns import $SCRATCH/nxdns-config.zon --data-dir $SCRATCH/data --force -> imported /…/scratchpad/nxdns-config.zon +> $ nxdns import $SCRATCH/etc/smaller.zon --data-dir $SCRATCH/dbmode --allow-delete +> imported /…/smaller.zon > (exit 0) > ``` > -> The `systemctl stop`/`start` lines around them need root and an installed -> service and were not run; `$EDITOR` is yours to run. +> The first of those three is the additive case that needs no flag; the second +> file replaced the upstream, which is an identity change and therefore a +> delete. The `systemctl stop`/`start` lines need root and an installed service +> and were not run; `$EDITOR` is yours to run. ## Upgrading to a build of your own diff --git a/docs/reference/api.md b/docs/reference/api.md index 1525f6a..866de0e 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -31,6 +31,9 @@ below carries all 56 of its entries. - Mutations to groups, blocklists, rules, local records, forward zones, clients and client prefixes take effect live. Upstreams and `/api/settings` are restart-required. +- Every route has a policy class — `read`, `config_write` or `runtime_action` — + and in file mode the `config_write` routes are refused. See + [Configuration authority](#configuration-authority). ## Authentication @@ -113,70 +116,140 @@ to the capacity is admitted and the long-run rate holds. holds at most 32 concurrent streams in total; when all slots are taken, the answer is a 503. +## Configuration authority + +Which authority is live decides whether the API may write configuration. Under +`nxdns run` the database is authority and every route behaves as it always has. +Under `nxdns run --config FILE` the file is authority, and the routes that would +edit configuration are refused: the file is the only place configuration +changes, and a restart is what applies them. + +### The refusal + +A `config write` route in file mode answers **403** with the ordinary error +envelope: + +```json +{"error":"configuration is managed by /etc/nxdns/config.zon; edit the file and restart"} +``` + +There is no `code` field and no richer body. 403 is used for nothing else in +this API, so the status alone is the machine-readable part, and a client that +wants to know the mode in advance reads it from `GET /api/settings` rather than +probing for errors. + +**401 comes first.** The router matches the path, spends a rate-limit token, +checks the session, and only then checks the policy. So an unauthenticated +request to a `config write` route in file mode is a 401, not a 403 — answering +403 first would tell an anonymous caller which routes exist. + +`runtime action` and `read` routes are unaffected in both modes. Pausing +blocking, refreshing blocklists, reloading certificates and logging in are +operations on a running process, not statements about configuration, so a +file-mode box still does all of them. + +`DELETE /api/clients/{id}` is the one route whose answer depends on the row. +Deleting a client the file does not declare is a runtime action and succeeds: +without it, a mis-identified or departed device would be immortal in file mode, +since the file can add addresses but never remove one it has never named. +Deleting a client the file *does* declare contradicts the file, and answers the +same 403. + +### Discovering the authority + +`GET /api/settings` carries an `authority` object: + +| Field | Meaning | +| --- | --- | +| `mode` | `"database"` or `"managed_file"`. | +| `path` | The managed file's path, or `null` in database mode. | +| `reconciled_at` | Unix seconds when this process loaded the file, or `null` in database mode. | + +All three keys are always present; the two nullable ones carry `null` rather +than being omitted, so a client can read `authority.mode` without probing. + +```json +{"mode": "database", "path": null, "reconciled_at": null} +{"mode": "managed_file", "path": "/etc/nxdns/config.zon", "reconciled_at": 1786474016} +``` + +The route requires a session, which is why the filesystem path is here rather +than on the open `/api/version` and `/api/health`. + +`reconciled_at` answers exactly one question: **when did this process last read +the file?** Compare it against the file's mtime to spot a restart that has not +happened yet. It is a hint and not a verdict, in both directions — a clock that +stepped, or a copy that preserved mtimes (`git checkout`, `rsync -a`), can make +a newer file look older, and the database can change without either timestamp +moving. It does not tell you whether the file and the running configuration +agree; answering that would take content hashing, which nxdns deliberately does +not do. + ## Operations Auth `open` means no session is required; `session` means a valid session cookie is required whenever a password is set. Rate limit `counted` spends a token; -`exempt` never consults the limiter. +`exempt` never consults the limiter. Policy `config write` is the class refused +in file mode; `read` and `runtime action` are always served. -| Method | Path | Auth | Rate limit | Purpose | -|---|---|---|---|---| -| GET | `/metrics` | open | exempt | Prometheus metrics | -| GET | `/api/health` | open | exempt | Health rollup | -| GET | `/api/version` | open | counted | Build and uptime | -| GET | `/api/openapi.yaml` | open | counted | This API's OpenAPI document | -| POST | `/api/auth/login` | open | counted | Log in | -| POST | `/api/auth/logout` | session | counted | Log out | -| GET | `/api/queries` | session | counted | Query log page | -| GET | `/api/queries/live` | session | exempt | Live query stream (server-sent events) | -| GET | `/api/stats` | session | counted | Totals for a period | -| GET | `/api/stats/timeseries` | session | counted | Bucketed counts for a period | -| GET | `/api/lookup` | session | counted | Explain a domain | -| GET | `/api/upstream/health` | session | counted | Upstream pool health | -| GET | `/api/groups` | session | counted | List groups | -| POST | `/api/groups` | session | counted | Create a group | -| GET | `/api/groups/{id}` | session | counted | Read a group | -| PUT | `/api/groups/{id}` | session | counted | Update a group | -| DELETE | `/api/groups/{id}` | session | counted | Delete a group | -| GET | `/api/groups/{id}/sources` | session | counted | Blocklist sources assigned to a group | -| PUT | `/api/groups/{id}/sources` | session | counted | Replace the assignment | -| GET | `/api/blocklists` | session | counted | List blocklist sources | -| POST | `/api/blocklists` | session | counted | Add a blocklist source | -| POST | `/api/blocklists/update` | session | counted | Refresh every enabled source now | -| GET | `/api/blocklists/{id}` | session | counted | Read a blocklist source | -| PUT | `/api/blocklists/{id}` | session | counted | Update a blocklist source | -| DELETE | `/api/blocklists/{id}` | session | counted | Delete a blocklist source | -| GET | `/api/rules` | session | counted | List rules | -| POST | `/api/rules` | session | counted | Create a rule | -| GET | `/api/rules/{id}` | session | counted | Read a rule | -| PUT | `/api/rules/{id}` | session | counted | Update a rule | -| DELETE | `/api/rules/{id}` | session | counted | Delete a rule | -| GET | `/api/local-records` | session | counted | List local DNS records | -| POST | `/api/local-records` | session | counted | Create a local record | -| GET | `/api/local-records/{id}` | session | counted | Read a local record | -| PUT | `/api/local-records/{id}` | session | counted | Update a local record | -| DELETE | `/api/local-records/{id}` | session | counted | Delete a local record | -| GET | `/api/forward-zones` | session | counted | List forward zones | -| POST | `/api/forward-zones` | session | counted | Create a forward zone | -| GET | `/api/forward-zones/{id}` | session | counted | Read a forward zone | -| PUT | `/api/forward-zones/{id}` | session | counted | Update a forward zone | -| DELETE | `/api/forward-zones/{id}` | session | counted | Delete a forward zone | -| GET | `/api/clients` | session | counted | List clients | -| GET | `/api/clients/{id}` | session | counted | Read a client | -| PUT | `/api/clients/{id}` | session | counted | Rename or regroup a client | -| DELETE | `/api/clients/{id}` | session | counted | Forget a client | -| GET | `/api/client-prefixes` | session | counted | List client prefixes | -| PUT | `/api/client-prefixes` | session | counted | Replace the prefix table | -| GET | `/api/upstreams` | session | counted | List upstream resolvers | -| POST | `/api/upstreams` | session | counted | Add an upstream | -| GET | `/api/upstreams/{id}` | session | counted | Read an upstream | -| PUT | `/api/upstreams/{id}` | session | counted | Update an upstream | -| DELETE | `/api/upstreams/{id}` | session | counted | Delete an upstream | -| GET | `/api/pause` | session | counted | Read the pause state | -| POST | `/api/pause` | session | counted | Pause or resume blocking | -| GET | `/api/settings` | session | counted | Read the scalar settings | -| PUT | `/api/settings` | session | counted | Update settings | -| POST | `/api/certs/reload` | session | counted | Reload the TLS certificates from disk | +| Method | Path | Auth | Rate limit | Policy | Purpose | +|---|---|---|---|---|---| +| GET | `/metrics` | open | exempt | read | Prometheus metrics | +| GET | `/api/health` | open | exempt | read | Health rollup | +| GET | `/api/version` | open | counted | read | Build and uptime | +| GET | `/api/openapi.yaml` | open | counted | read | This API's OpenAPI document | +| POST | `/api/auth/login` | open | counted | runtime action | Log in | +| POST | `/api/auth/logout` | session | counted | runtime action | Log out | +| GET | `/api/queries` | session | counted | read | Query log page | +| GET | `/api/queries/live` | session | exempt | read | Live query stream (server-sent events) | +| GET | `/api/stats` | session | counted | read | Totals for a period | +| GET | `/api/stats/timeseries` | session | counted | read | Bucketed counts for a period | +| GET | `/api/lookup` | session | counted | read | Explain a domain | +| GET | `/api/upstream/health` | session | counted | read | Upstream pool health | +| GET | `/api/groups` | session | counted | read | List groups | +| POST | `/api/groups` | session | counted | config write | Create a group | +| GET | `/api/groups/{id}` | session | counted | read | Read a group | +| PUT | `/api/groups/{id}` | session | counted | config write | Update a group | +| DELETE | `/api/groups/{id}` | session | counted | config write | Delete a group | +| GET | `/api/groups/{id}/sources` | session | counted | read | Blocklist sources assigned to a group | +| PUT | `/api/groups/{id}/sources` | session | counted | config write | Replace the assignment | +| GET | `/api/blocklists` | session | counted | read | List blocklist sources | +| POST | `/api/blocklists` | session | counted | config write | Add a blocklist source | +| POST | `/api/blocklists/update` | session | counted | runtime action | Refresh every enabled source now | +| GET | `/api/blocklists/{id}` | session | counted | read | Read a blocklist source | +| PUT | `/api/blocklists/{id}` | session | counted | config write | Update a blocklist source | +| DELETE | `/api/blocklists/{id}` | session | counted | config write | Delete a blocklist source | +| GET | `/api/rules` | session | counted | read | List rules | +| POST | `/api/rules` | session | counted | config write | Create a rule | +| GET | `/api/rules/{id}` | session | counted | read | Read a rule | +| PUT | `/api/rules/{id}` | session | counted | config write | Update a rule | +| DELETE | `/api/rules/{id}` | session | counted | config write | Delete a rule | +| GET | `/api/local-records` | session | counted | read | List local DNS records | +| POST | `/api/local-records` | session | counted | config write | Create a local record | +| GET | `/api/local-records/{id}` | session | counted | read | Read a local record | +| PUT | `/api/local-records/{id}` | session | counted | config write | Update a local record | +| DELETE | `/api/local-records/{id}` | session | counted | config write | Delete a local record | +| GET | `/api/forward-zones` | session | counted | read | List forward zones | +| POST | `/api/forward-zones` | session | counted | config write | Create a forward zone | +| GET | `/api/forward-zones/{id}` | session | counted | read | Read a forward zone | +| PUT | `/api/forward-zones/{id}` | session | counted | config write | Update a forward zone | +| DELETE | `/api/forward-zones/{id}` | session | counted | config write | Delete a forward zone | +| GET | `/api/clients` | session | counted | read | List clients | +| GET | `/api/clients/{id}` | session | counted | read | Read a client | +| PUT | `/api/clients/{id}` | session | counted | config write | Rename or regroup a client | +| DELETE | `/api/clients/{id}` | session | counted | runtime action | Forget a client | +| GET | `/api/client-prefixes` | session | counted | read | List client prefixes | +| PUT | `/api/client-prefixes` | session | counted | config write | Replace the prefix table | +| GET | `/api/upstreams` | session | counted | read | List upstream resolvers | +| POST | `/api/upstreams` | session | counted | config write | Add an upstream | +| GET | `/api/upstreams/{id}` | session | counted | read | Read an upstream | +| PUT | `/api/upstreams/{id}` | session | counted | config write | Update an upstream | +| DELETE | `/api/upstreams/{id}` | session | counted | config write | Delete an upstream | +| GET | `/api/pause` | session | counted | read | Read the pause state | +| POST | `/api/pause` | session | counted | runtime action | Pause or resume blocking | +| GET | `/api/settings` | session | counted | read | Read the scalar settings | +| PUT | `/api/settings` | session | counted | config write | Update settings | +| POST | `/api/certs/reload` | session | counted | runtime action | Reload the TLS certificates from disk | There is no `POST /api/clients`: client rows come from DNS activity or import, never from the API. @@ -194,6 +267,11 @@ write-only (accepted on a `PUT`, never returned, hashed before storage), and `web.password_hash` is neither readable nor directly writable, because a client that could install a hash could install one whose password it already knows. +In file mode `PUT /api/settings` is refused with the 403 above, password changes +included. The password then lives where the rest of the configuration lives: set +`web.password` in the file and restart. See +[Password and hash](configuration.md#password-and-hash). + ## Schemas Request and response schemas for every operation live in the OpenAPI document: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 18d1167..99052ea 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -9,9 +9,10 @@ of truth: `src/cli.zig`. Every flag takes both spellings, `--flag value` and `--flag=value`. An attached value that is empty (`--config=`) is a missing value, not an empty path. -`--force` is boolean and takes no value at all, so `--force=1` is not a spelling -of any flag this program has. A flag is rejected by the subcommand that has no -use for it: `--web-dev` outside `run` is an unknown flag, not a no-op. +`--allow-delete` is boolean and takes no value at all, so `--allow-delete=1` is +not a spelling of any flag this program has. A flag is rejected by the +subcommand that has no use for it: `--web-dev` outside `run` is an unknown flag, +not a no-op. ## `run` @@ -20,12 +21,81 @@ Serves DNS until SIGINT or SIGTERM. | Flag | Meaning | | --- | --- | | `--data-dir DIR` | Data directory (default `/var/lib/nxdns`). Created at mode 0700 if missing. | -| `--config FILE` | Seed configuration file (default `/etc/nxdns/config.zon`). Read only when the database has never been configured. | +| `--config FILE` | Make FILE the sole source of configuration and reconcile the database onto it at every start. No default: without this flag the database is the configuration and no file is read. | | `--web-dev DIR` | Serve the web interface from DIR instead of the embedded assets, with no cache headers. Development only. | -A seed file that is unparseable, oversized or invalid prints its diagnostics and -exits 2 — the same code `check` and `import` give for the same file. See -[exit codes](#exit-codes). +### Which authority the invocation selects + +The presence of `--config` picks the authority, and nothing else does. There is +no default path, no probe of `/etc/nxdns`, and nothing recorded in the database: +a configuration file sitting at `/etc/nxdns/config.zon` that no flag names +changes nothing at all. + +| Invocation | Authority | What a start does | +| --- | --- | --- | +| `nxdns run` | The database | Serves what `config.db` holds. Nothing reads a file. | +| `nxdns run --config FILE` | FILE | Reads and validates FILE, reconciles the database onto it, then serves. | + +The first log line after the migrations names the mode, so a journal says which +authority was live: + +``` +info(nxdns): authority: database +info(nxdns): authority: file (/etc/nxdns/config.zon) +``` + +In file mode the reconcile prints what it changed before that line — per-table +inserted (`+`), updated (`~`) and deleted (`-`) counts, the settings keys whose +values changed, and any change to whether the admin password is set: + +``` +reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +45 ~0 -0; +settings keys changed: dns.bind_ipv4 dns.port web.bind web.port … +web authentication is now enabled +``` + +A start whose file matches the database writes nothing and says so: + +``` +reconciled '/etc/nxdns/config.zon': no changes +``` + +Blocklist state is not declarative and survives every reconcile: a source whose +URL the file still names keeps its row id, its checksum, its counters and its +compiled `.list` and `.wild`, so a restart in file mode downloads +nothing. Editing a source's URL is a new identity — a new row, a new id, and a +fresh download. + +### Failing to start in file mode + +File mode fails closed. A file that is missing, unreadable, unparseable, +oversized or invalid stops the start; nxdns never falls back to the database, +because a fallback turns a deploy typo into a silently stale configuration. + +``` +FAIL /etc/nxdns/config.zon: no such file +nxdns run failed: ManagedConfigUnreadable +run `nxdns check` to see the configuration in full +``` + +That is exit 2, and `check --config` on the same path agrees. Only path-class +open failures map that way — the file is not there, or the process may not read +it. An open that fails for a reason a retry could clear, such as +file-descriptor exhaustion or an I/O error, is exit 1: the box is wrong, not the +configuration. See [exit codes](#exit-codes). + +The exit-2 agreement covers `run --config` and `check --config`, which read the +managed file through one shared helper. It does **not** extend to `import`'s +positional argument: a missing file there is `import failed: FileNotFound`, exit +1. That is deliberate rather than an oversight — the managed file is a +declarative input an operator deploys, so its absence is a fact about the +configuration, while `import`'s argument is a path typed at a prompt, and a +mistyped path is a failed command rather than a verdict on anything. + +Run `nxdns check --config FILE` before restarting anything that deploys a file. +It grades every declarative fault `run` would hit — read, parse, size, +validation — through the same code, which is what makes it a usable precondition +in an Ansible handler. ## `check` @@ -52,7 +122,7 @@ first. What it checks, in order: | Flag | Meaning | | --- | --- | | `--data-dir DIR` | Data directory to look for `config.db` in. | -| `--config FILE` | Check this file instead of the database. | +| `--config FILE` | Grade this file instead of the database. | ### Failures and warnings @@ -71,23 +141,38 @@ The last line is a summary, and it never contradicts the lines above it: ### Source selection -- `--config FILE` given explicitly: check that file, nothing else. -- Otherwise, if `/config.db` exists: check the database — the right - default, since the database is the truth on a configured server. It is opened - immutable, so `check` writes nothing to it; see - [what `check` does not do](#what-check-does-not-do). -- Otherwise, if the default config file path exists: check it. -- Otherwise: "nothing to check", exit 2. +The flag decides, exactly as it does for `run`. There is no fallback and no +probing of a default path: -The first line of output always names which source was checked. A file larger -than 4 MiB fails with `larger than 4194304 bytes`; a ZON syntax error is +- `--config FILE`: grade that file, and never open the database. +- No `--config`: grade `/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 `, or make a file the source of truth with `nxdns run --config ` +``` + +The first line of output otherwise always names which source was checked. A file +larger than 4 MiB fails with `larger than 4194304 bytes`; a ZON syntax error is reported with its line and column. A named file that is missing or unreadable is a finding like any other, not an I/O failure that escapes the run: `FAIL : no such file` or -`FAIL : not readable`, exit 2. That is the same code the implicit path -gives when there is nothing to check, so naming the file does not change what a -missing file costs. +`FAIL : not readable`, exit 2. + +What `check --config` cannot see is the reconcile itself. It needs no database, +so faults that only a write can produce — a disk that is full, a lock held by a +restart that started first — are invisible to it. Those are runtime failures, +exit 1, and they are not verdicts on the file. ### What `check` does not do @@ -133,6 +218,15 @@ there. A directory that exists without a `config.db` is not that case: the database is opened with create semantics, so an empty `config.db` is created and migrated, and the export is of a default configuration. +The output is the same in both authority modes — nothing marks a file as +exported from a file-mode box. That is what lets `export` be the adoption tool: +the file you check is the file you deploy. + +`web.password` is always written as `null` and `web.password_hash` carries the +stored value, so an export re-imports without anyone knowing the password. A +`null` password is not the same statement as an empty one: see +[`web.password` and `web.password_hash`](configuration.md#password-and-hash). + | Flag | Meaning | | --- | --- | | `--data-dir DIR` | Data directory holding `config.db`. | @@ -142,25 +236,79 @@ See [back up and restore](../how-to/back-up-and-restore.md). ## `import FILE` -Validates FILE and replaces the whole configuration with it in one transaction. -Prints every validation problem; a failed import leaves the database untouched. -Refuses a database that already holds configuration unless `--force` is given -(`DatabaseNotEmpty`, exit 2). The check measures what an operator set, not what -the network did: client rows the DNS path materialised from traffic never -trigger the refusal on their own. Creates the data directory at mode 0700 if it -is missing. - -Client history survives the replacement. An address the database already knew -keeps its first-seen and last-seen even when FILE names it; only an address it -has never seen takes the import's clock. A client FILE leaves out is removed, -history included. +Converges the database onto FILE in one transaction — the same reconcile a +file-mode `run` performs, done once from the command line. Prints every +validation problem; a failed import leaves the database untouched. Creates the +data directory at mode 0700 if it is missing. `FILE` is positional and may appear before or after the flags. | Flag | Meaning | | --- | --- | | `--data-dir DIR` | Data directory holding `config.db` (created if missing). | -| `--force` | Replace a database that already holds configuration. | +| `--allow-delete` | Apply a file whose diff deletes rows. | + +**`import` is a stop-first operation.** It rewrites configuration underneath a +process that read it at startup, and a running server notices only some of it: +filtering picks up the imported rows at the next reload, while upstreams, +listeners and settings stay at their boot values until a restart. + +### The delete gate + +Rows the database holds and FILE does not name are deleted. That is the point of +a declarative apply, and it is also how a mistaken `nxdns import ./wrong.zon` +empties a configured server, so it takes a flag: + +``` +FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it +import failed: DestructiveImport +``` + +That is exit 2, and the transaction rolls back. The message names every table +with a non-zero delete count, so you can tell an intended pruning from a wrong +file before applying anything. + +An import that only adds rows, or only edits them, needs no flag. "Edit" here +means a change to a row nxdns can still recognise as the same row. Each table +has one column, or one tuple, that establishes identity: + +| Table | Identity | +| --- | --- | +| `blocklist_sources`, `upstreams` | `url` | +| `groups` | `name` | +| `clients` | `ip` | +| `client_prefixes` | `prefix` | +| `forward_zones` | `zone` | +| `local_records` | `(name, rtype, value)` | +| `rules` | `(group, pattern, kind, action)` | + +Change anything else on a row — a source's name, a group's `safe_search`, a +client's group — and it is an edit, applied without a flag. Change the identity +itself, such as renaming a group or correcting a typo in an upstream URL, and +the engine sees a row that vanished and a row that appeared: that needs +`--allow-delete`. + +### What survives an import + +Runtime state is not declarative and is preserved by identity, not by luck. A +blocklist source whose URL is unchanged keeps its row id, its checksum, its +counters and its compiled files, so an import costs no downloads. Clients the +DNS path materialised from traffic are kept whole; naming one in the file +promotes that row in place, keeping its first-seen and last-seen. Observed +clients whose group the file no longer declares are moved to the `default` +group rather than deleted with it, and none of that ever trips the delete gate. + +### `import` against a file-mode box + +It behaves like any other import. Nothing in the database records that a file +governs it — authority lives in the invocation — so `import` neither detects nor +refuses that case. The next restart's reconcile converges the database back to +the file and its summary reports what it corrected. A source the import deleted +comes back with a new row id, which means a fresh download of the whole list. + +If a restart and an import race for the write lock, one of them simply wins: both +take `BEGIN IMMEDIATE` under a 5-second busy timeout, so the outcome is an +ordering, never a corrupted database. ## `version` @@ -185,28 +333,44 @@ Code 2 means the same thing from every subcommand. `src/config/faults.zig` holds the one list of errors that mean "the configuration the operator supplied is wrong", and `run`, `check` and `import` all ask it, so a rejected file exits 2 whichever command read it. The list is every error the validator raises, plus -`ParseZon`, `ConfigTooLarge`, `NoUsableUpstreams` and `BadCertificate`. In -practice that covers a seed file with a syntax error, one larger than 4 MiB, one -with no `default` group (`MissingDefaultGroup`), one with no enabled upstream -(`NoUpstreams`), a bad bind address, a bad rate limit, an unusable certificate, -and `password` and `password_hash` set together. +`ParseZon`, `ConfigTooLarge`, `NoUsableUpstreams`, `BadCertificate` and +`ManagedConfigUnreadable`. In practice that covers a file with a syntax error, +one larger than 4 MiB, one with no `default` group (`MissingDefaultGroup`), one +with no enabled upstream (`NoUpstreams`), a bad bind address, a bad rate limit, +an unusable certificate, `password` and `password_hash` set together, and a +`--config` path that is absent or unreadable. -When `run` exits 2 it points at the diagnosis on stderr, whether the fault came -from the seed file or from the database it loaded: +The last of those is the one deliberate seam. A file nxdns cannot open is a +configuration fault only when the *path* is the problem — the file is missing, +permissions deny it, a path component is not a directory. Every other open +failure, such as running out of file descriptors, is exit 1. The distinction +earns its keep under the shipped systemd unit, which stops the service on exit 2 +rather than restarting it: a transient box fault graded as a configuration fault +would take the resolver down until someone noticed. + +When `run` exits 2 it points at the diagnosis on stderr, whichever authority the +fault came from: ``` run `nxdns check` to see the configuration in full ``` -`check` exits 2 for those faults and also when a probed upstream failed, when a -named configuration file is missing or unreadable, when the database cannot be -read or is not at this binary's schema version, and when there was nothing to -check. Warnings never contribute. +On a box with no configuration at all, `run` and `check` add the line that names +both ways to get one: -`import` exits 2 for those faults and for `DatabaseNotEmpty`. That last one is -deliberately not a configuration fault — it reports the state of the database -rather than the content of a file — and `import` decides it for itself; the -answer to it is `--force`, not an edit. +``` +load one with `nxdns import `, or make a file the source of truth with `nxdns run --config ` +``` + +`check` exits 2 for those faults and also when a probed upstream failed, when a +named configuration file is missing or unreadable, and when the database cannot +be read, is absent, or is not at this binary's schema version. Warnings never +contribute. + +`import` exits 2 for those faults and for `DestructiveImport`. That last one is +deliberately not a configuration fault — it reports what applying the file would +delete, rather than anything wrong with its content — and `import` decides it +for itself; the answer to it is `--allow-delete`, not an edit. `OutOfMemory` is exit 1 even when problems were recorded, because the report is then incomplete. Every other error is 1. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 5a95006..e1ad70d 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -4,7 +4,7 @@ Every section, field and collection nxdns accepts, with its type, default, unit, validation rule and the subsystem that consumes it. Source of truth: `src/config/model.zig` (the model and the defaults), -`src/config/validate.zig` (the rules), `src/config/{bootstrap,import,export}.zig` +`src/config/validate.zig` (the rules), `src/config/{loader,reconcile,import,export}.zig` (the lifecycle). For how the file, the database and `export`/`import` relate to each other, see @@ -17,7 +17,7 @@ reference](cli.md). The file is ZON: a top-level anonymous struct whose fields are the sections and collections below. Enum values are ZON enum literals (`.level = .err`, `.response = .nxdomain`). Strings are double-quoted. The file may be at most -4 MiB (`max_config_bytes` in `src/config/import.zig`); beyond that the error is +4 MiB (`max_config_bytes` in `src/config/loader.zig`); beyond that the error is `ConfigTooLarge`. A syntax error is reported with its line and column. Absent fields keep their defaults, both in the file and in the database. A @@ -38,7 +38,7 @@ Storage paths are process arguments, not configuration: | Argument | Default | Meaning | | --- | --- | --- | | `--data-dir DIR` | `/var/lib/nxdns` | Holds `config.db` and `querylog.db`; see [files and directories](files-and-directories.md). | -| `--config FILE` | `/etc/nxdns/config.zon` | Names the seed file. | +| `--config FILE` | — | On `run`, makes FILE the sole source of configuration; on `check`, grades FILE instead of the database. No default: without it the database is the configuration. | | `--web-dev DIR` | — | `run` only; serves the web interface from a directory instead of the embedded assets. | ## Scalar sections @@ -115,8 +115,8 @@ The web interface and REST API. | `web.enabled` | bool | true | — | — | gates the whole web stack: server, sessions, SSE hub, API limiter (`src/app.zig`) | | `web.bind` | string | `"0.0.0.0"` | IP address | must parse as an IP address of either family | web listener bind (`src/web/server.zig`) | | `web.port` | u16 | 8080 | port | 1–65535 (0 is refused) | web listener port | -| `web.password` | string | `""` | — | must not be set together with `web.password_hash` | operator input only; hashed at import and discarded. Never a settings row — see [Password and hash](#password-and-hash) | -| `web.password_hash` | string | `""` | — | — | argon2id PHC string verified at login (`src/web/auth.zig`); `""` disables authentication | +| `web.password` | optional string | absent | — | must not be set together with `web.password_hash`; the empty string is refused | operator input only; hashed and discarded. Never a settings row — see [Password and hash](#password-and-hash) | +| `web.password_hash` | optional string | absent | — | — | argon2id PHC string verified at login (`src/web/auth.zig`); `""` disables authentication, absent keeps the stored hash | | `web.session_ttl_hours` | u16 | 24 | hours | at least 1 | session expiry and cookie `Max-Age` (`src/web/auth.zig`) | | `web.api_rate_limit_per_min` | u32 | 300 | requests per minute | at least 1 | API token-bucket limiter (`src/web/api_limiter.zig`) | | `web.api_localhost_exempt` | bool | true | — | — | loopback requests skip the API limiter | @@ -341,27 +341,59 @@ deadline. ## Password and hash -Exactly one of `web.password` and `web.password_hash` may be set; setting both -is refused (`PasswordAndHashBothSet` — ambiguity in a security setting). +Both fields are optional, and the difference between *absent* and *empty* is the +whole design. Absent means "keep whatever is stored". Empty means "there is no +password". -- `web.password` is operator input only. At import time it is hashed with - argon2id (OWASP parameters: t=2, m=19 MiB, p=1, PHC encoding) into - `web.password_hash` and discarded. There is no `web.password` settings row, - and `nxdns export` always writes `.password = ""`. +| The file says | What happens to the stored hash | +| --- | --- | +| Neither field | Untouched. Authentication stays exactly as it was. | +| `.password = "some-password"` | Verified against the stored hash; kept when it matches, replaced with a fresh hash when it does not. | +| `.password = ""` | Refused, with a diagnostic naming the remedy. | +| `.password_hash = "$argon2id$…"` | Written verbatim. | +| `.password_hash = ""` | Cleared, which disables authentication. | +| Both fields | Refused (`PasswordAndHashBothSet` — ambiguity in a security setting). | + +Silence has to mean "keep", because the alternative is a trap. An operator who +exports a configuration and trims the long PHC string out of it before +committing the file to git means "leave the password alone", not "open the admin +interface to the LAN". So disabling authentication takes the explicit empty +string, and the empty *plaintext* — which would otherwise hash into a real hash +that no login can ever satisfy — is refused outright: + +``` +FAIL web.password: password is set to the empty string; omit the field to keep the stored password, or set password_hash = "" to disable authentication +``` + +- `web.password` is operator input only. It is hashed with argon2id (OWASP + parameters: t=2, m=19 MiB, p=1, PHC encoding) into `web.password_hash` and + discarded. There is no `web.password` settings row, and `nxdns export` always + writes `.password = null`. - `web.password_hash` is the stored argon2id PHC string. Supplying it directly, for example from a previous export, is how a backup restores authentication without knowing the password. -- Both empty disables web authentication entirely. -Because the export carries the hash and re-importing an exported file takes the -"password is empty" branch, the export/import round trip preserves the hash -byte for byte. See [set up admin +A plaintext password that has not changed is verified rather than re-hashed, so +applying the same file twice leaves the same bytes in the database. That is what +keeps the export/import round trip byte-stable with a password in the file. It +costs a full argon2id computation either way — the verification is not a +shortcut, and caching the plaintext to skip it would be a security bug. + +Any change to whether a password is set is announced at startup, never left as a +count: + +``` +web authentication is now enabled +web authentication is now disabled +``` + +See [set up admin authentication](../how-to/set-up-admin-authentication.md). ## Validation errors -`nxdns check`, `nxdns import` and the `nxdns run` that seeds a database from the -file all print one `FAIL path: message` line per problem, and report every +`nxdns check`, `nxdns import` and a `nxdns run --config` that reads the file +all print one `FAIL path: message` line per problem, and report every problem rather than the first. A finding that is legal but almost certainly unintended is prefixed `WARN` instead: it does not change the exit code, and it is printed by all three even when nothing failed, so an accepted configuration @@ -461,7 +493,7 @@ upstream. Everything else keeps its default. .cache = .{ .size = 10000, .negative_ttl_max = 3600 }, - // Web UI on 8080. The password is hashed at import and never stored; + // Web UI on 8080. The password is hashed and never stored as plaintext; // leave .password_hash out when setting .password (they are exclusive). .web = .{ .enabled = true, diff --git a/docs/reference/files-and-directories.md b/docs/reference/files-and-directories.md index 225e598..8ac862e 100644 --- a/docs/reference/files-and-directories.md +++ b/docs/reference/files-and-directories.md @@ -10,11 +10,9 @@ snapshots), `src/platform/logging.zig` (the log file). Default `/var/lib/nxdns`, overridable with `--data-dir DIR`. `nxdns run` and `nxdns import` create it and its parents at mode 0700 when it is missing; `nxdns check` and `nxdns export` do not create it. `export` fails if it is not -there. `check` opens it only on the branch that resolved to the database, so an -absent data directory is not in itself a failure: an explicit `--config FILE` -never looks at the directory, and without one `check` falls back to the default -configuration file, or prints "nothing to check" and exits 2 when neither source -exists. +there. `check` opens it only when no `--config FILE` was given: with that flag it +grades the file and never looks at the directory at all. Without it, an absent +`config.db` is a failure naming the two ways to get one, exit 2. `run`, `import` and `export` go through `DataDir.openConfigDb`, which opens `config.db` read/write, chmods it to 0600, enables WAL — creating @@ -39,7 +37,7 @@ older ones from the main file. That is the "uncheckpointed changes" failure in | Path | What it is | Mode | | --- | --- | --- | -| `config.db` | The configuration database — the single source of truth, including `web.password_hash`. | 0600 | +| `config.db` | The configuration database, including `web.password_hash`. The source of truth in database mode; in file mode it is the runtime substrate the file is reconciled onto (see [the configuration file](#the-configuration-file)). | 0600 | | `config.db-wal`, `config.db-shm` | SQLite write-ahead log and shared-memory index for `config.db`. Created by `run`, `import` and `export` when WAL is enabled, inheriting the main file's permissions. `check` creates neither. | 0600 | | `querylog.db` | The query log: every domain every client asked for. Expendable — if it is missing or unusable it is recreated empty. | 0600 | | `querylog.db-wal`, `querylog.db-shm` | WAL sidecars for `querylog.db`. | 0600 | @@ -112,9 +110,20 @@ than being created world-readable. ## The configuration file -Default `/etc/nxdns/config.zon`, overridable with `--config FILE`. nxdns reads -it and never writes it: it seeds an unconfigured database once and is ignored -afterwards. nxdns does not create the file or its directory. +There is no default path. `--config FILE` names the file, and without that flag +no file is read at all — a `config.zon` sitting in `/etc/nxdns` that no +invocation names is inert. `/etc/nxdns/config.zon` is a convention the packaging +follows, not a location nxdns probes. + +nxdns reads the file and never writes it, in either authority mode. It does not +create the file or its directory either; the systemd unit's +`ConfigurationDirectory=nxdns` creates `/etc/nxdns`, and the same unit's +`ReadOnlyPaths=/etc/nxdns` denies the service write access to it, so the file +cannot be modified by the process that reads it. + +Under `run --config FILE` the file is the configuration and the database is the +runtime substrate the server reads from: every start reconciles the one onto the +other. So in that mode `config.db` is not the backup — the file is. `nxdns export --out FILE` writes a ZON file at mode 0600 through a temporary file and a rename. That file carries `web.password_hash`, so treat exports as diff --git a/docs/tutorial/first-run.md b/docs/tutorial/first-run.md index 095f73c..31e0fb3 100644 --- a/docs/tutorial/first-run.md +++ b/docs/tutorial/first-run.md @@ -9,8 +9,13 @@ delete the directory. Follow the steps in order. Each one says what it did. Every command below was executed on x86_64 Linux with Zig 0.16.0, Node.js -24.14.1, dig 9.20.26 and curl 8.21.0. The only thing substituted during that run -was the tutorial directory. +24.14.1, dig 9.20.26 and curl 8.21.0. Steps 4 to 11, 13 and 14 were re-run end +to end for this revision, and the transcripts are that run's output with the +tutorial directory substituted. Two things were not re-run: the browser page in +step 12 — its endpoints were exercised, the page itself was not opened — and the +two build commands in steps 1 and 2, which had already produced the binary under +test. The ZON block at the end of step 14 was checked with `nxdns check +--config` rather than started. ## What you need @@ -30,7 +35,7 @@ cd web && npm ci && npm run build && cd .. ``` This produces `web/dist`. Do not skip it. A plain `zig build` embeds -`web/dist-placeholder`, a one-page status stub, and you would reach step 10 and +`web/dist-placeholder`, a one-page status stub, and you would reach step 12 and find no admin interface there. ## 2. Build nxdns @@ -75,6 +80,11 @@ itself, so a configuration missing either one is rejected. Whichever command reads the file says the same thing and stops the same way: `run`, `nxdns check` and `nxdns import` all print the problem and exit 2. +This tutorial loads the file into the database once and then runs nxdns against +the database, which is what the packaged systemd unit does. There is a second +way to run nxdns, where the file itself stays the configuration; step 14 shows +what changes. + ## 4. Check the configuration before starting ```sh @@ -92,33 +102,44 @@ answers. It exits 2 when it found something that has to be fixed and 0 otherwise. It writes nothing and starts no listener, so you can run it as often as you like. -## 5. Start the server - -In your first terminal: +## 5. Load it into the database ```sh -zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data --config ~/nxdns-tutorial/config.zon +zig-out/bin/nxdns import ~/nxdns-tutorial/config.zon --data-dir ~/nxdns-tutorial/data ``` ``` info(migrations): config.db migrated from schema version 0 to 2 -info(config_bootstrap): seeded the database from '/home/you/nxdns-tutorial/config.zon' +imported /home/you/nxdns-tutorial/config.zon +``` + +The data directory did not exist; nxdns created it at mode 0700 along with +`config.db`. From here the database holds the configuration, and you will change +it through the API rather than by editing the file again. + +## 6. Start the server + +In your first terminal: + +```sh +zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data +``` + +``` info(querylog_schema): created querylog database '/home/you/nxdns-tutorial/data/querylog.db' info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 199 bytes +info(nxdns): authority: database info(nxdns): nxdns serving on udp [::1]:15353 udp 127.0.0.1:15353 tcp [::1]:15353 tcp 127.0.0.1:15353; 1 upstream(s); blocklist generation 1 info(web_server): web interface listening on 127.0.0.1:8080 ``` -The data directory did not exist; nxdns created it at mode 0700 along with -`config.db` and `querylog.db`. The line that matters is `seeded the database -from`: the configuration file was read into the database this once. From now on -the database is the truth and the file is ignored on every later start, which -you will see for yourself in step 11. Configuration changes go through the API, -the web interface, or `nxdns import`. +No `--config` here, and that is the point: `authority: database` says the +database is the configuration and no file was opened at all. The file you wrote +in step 3 has done its job. Leave this terminal running and switch to the second one. -## 6. Resolve a name +## 7. Resolve a name ```sh dig @127.0.0.1 -p 15353 example.com A +noall +answer @@ -131,10 +152,10 @@ example.com. 90 IN A 104.20.23.154 nxdns had no answer cached, so it forwarded the query to `https://cloudflare-dns.com/dns-query` over HTTPS and returned what came back. -You now have a working resolver. It blocks nothing yet: the log line in step 5 +You now have a working resolver. It blocks nothing yet: the log line in step 6 said `0 of 0 sources loaded`. -## 7. Add a blocklist source +## 8. Add a blocklist source ```sh curl -s -X POST http://127.0.0.1:8080/api/blocklists \ @@ -152,7 +173,7 @@ That is fine for a localhost tutorial and wrong for anything else; see Note the `"id":1` in the response. You need it in the next step. -## 8. Attach the source to the `default` group +## 9. Attach the source to the `default` group A blocklist source belongs to the installation. Which groups use it is a separate decision, which is what lets one group get a strict list and another @@ -190,7 +211,7 @@ curl -s -X PUT http://127.0.0.1:8080/api/groups/1/sources \ {"source_ids":[1]} ``` -## 9. Download the list +## 10. Download the list Adding a source registers it; it does not fetch it. Ask for a refresh: @@ -199,25 +220,25 @@ curl -s -X POST http://127.0.0.1:8080/api/blocklists/update ``` ```json -{"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1785683777,"last_success":1785683777,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":99277,"wildcards":0,"skipped_regex":0}]} +{"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1786473715,"last_success":1786473715,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":99559,"wildcards":0,"skipped_regex":0}]} ``` The download is about 3 MB and takes a few seconds. Watch the first terminal until this appears: ``` -info(blocklist_manager): blocklist snapshot generation 5: 1 of 1 sources loaded, 3088703 bytes +info(blocklist_manager): blocklist snapshot generation 6: 1 of 1 sources loaded, 3096006 bytes ``` `1 of 1 sources loaded` is the line to wait for. nxdns builds each blocklist snapshot in full and swaps it in atomically, so queries keep being answered from the previous snapshot the whole time the new one is being built. After this, the -domain count in the JSON above — 99277 on the day this was run — is live. +domain count in the JSON above — 99559 on the day this was run — is live. From here on, the list is on disk under `~/nxdns-tutorial/data/blocklists`. Restarting nxdns does not re-download it. -## 10. Watch a domain get blocked +## 11. Watch a domain get blocked ```sh dig @127.0.0.1 -p 15353 doubleclick.net A +noall +answer @@ -249,7 +270,7 @@ yourself can, with a wildcard pattern such as `*.doubleclick.net`. So when you pick a domain to test, pick one that is literally in the file. `www.google-analytics.com` is another that is. -## 11. Open the web interface +## 12. Open the web interface Visit in a browser. This is the single-page application you built in step 1, served out of the binary. The dashboard shows query and @@ -257,7 +278,7 @@ block counts, and the Blocklists page shows the source you added with its domain count. (The endpoints behind those two pages were checked while writing this; the browser page itself was not opened on the verification host.) -## 12. Stop it +## 13. Stop it In the first terminal, press Ctrl-C. @@ -267,21 +288,61 @@ info(nxdns): shutting down nxdns catches SIGINT and SIGTERM, stops serving and exits 0. -Start it again with the same command as in step 5 and read the first log line: +Start it again with the same command as in step 6 and read the first log lines: ``` -info(config_bootstrap): configuration file ignored; the database is already configured -info(blocklist_manager): blocklist snapshot generation 1: 1 of 1 sources loaded, 3088703 bytes +info(blocklist_manager): blocklist snapshot generation 1: 1 of 1 sources loaded, 3096006 bytes +info(nxdns): authority: database ``` -The configuration file was not even opened, and the blocklist came off disk -rather than the network. The blocklist source, the group it is attached to, and -the query log all survived the restart because they live in -`~/nxdns-tutorial/data`. +The blocklist came off disk rather than the network. The source you added, the +group it is attached to, and the query log all survived the restart because they +live in `~/nxdns-tutorial/data`, which is what `authority: database` means in +practice. Press Ctrl-C again to stop this second process. Nothing is listening on 15353 or 8080 now, and nothing of nxdns is running. +## 14. See what the other mode does + +You have been running in database mode. The alternative is to hand the same file +back as the configuration, which is what `--config` means: + +```sh +zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data --config ~/nxdns-tutorial/config.zon +``` + +``` +reconciled '/home/you/nxdns-tutorial/config.zon': sources +0 ~0 -1; group_sources +0 ~0 -1; +info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 199 bytes +info(nxdns): authority: file (/home/you/nxdns-tutorial/config.zon) +``` + +**Read that first line.** The blocklist source is gone. That is not a bug — it is +the whole contract. The file you wrote in step 3 never mentioned a blocklist +source, and in file mode the file is the complete statement of what the +configuration is, so anything the database holds that the file does not name is +removed at every start. The reconcile said so in one line before doing it. + +The compiled list is still on disk and the query log is untouched; what changed +is the configuration, and it now matches the file exactly. + +Neither mode is the "advanced" one. Database mode suits a box someone +administers through the web interface. File mode suits a file kept in git and +deployed by a tool, where the deployed file being what is running matters more +than clicking. To have kept the blocklist here, you would put it in the file: + +```zon + .blocklist_sources = .{ + .{ .url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", .name = "StevenBlack hosts" }, + }, + .group_sources = .{ + .{ .group = "default", .source_url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts" }, + }, +``` + +Ctrl-C to stop it. + ## What you have now A resolver that answers real queries, a real blocklist of about 99000 domains @@ -292,9 +353,10 @@ directory you can delete: rm -rf ~/nxdns-tutorial ``` -You also saw the two things that surprise people most: the configuration file -seeds the database once and is ignored afterwards, and a blocklist source does -nothing until a group uses it. +You also saw the three things that surprise people most: a blocklist source +does nothing until a group uses it, which authority a start runs under is +printed rather than guessed, and in file mode anything the file does not name is +removed at the next start. ## Where to go next @@ -305,4 +367,4 @@ nothing until a group uses it. - [reference/configuration.md](../reference/configuration.md) — every field you did not set. - [explanation/configuration-model.md](../explanation/configuration-model.md) — - why the file seeds and the database rules. + why there are two authority modes and what each one is for. diff --git a/specs/milestone-20.md b/specs/milestone-20.md index 9278c98..dd000e0 100644 --- a/specs/milestone-20.md +++ b/specs/milestone-20.md @@ -579,10 +579,14 @@ No `std.log.err` in any new code, per the standing spec rule for new code - **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: `export` opens the DB immutable and - refuses with `WalPending` against a live instance's steady-state WAL - (db.zig:222-226, cli.md:121), and any UI edit landing between a live export - and the restart would be silently reverted by the first reconcile. Stopped, + 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. @@ -766,24 +770,29 @@ api.md/cli.md rows), and the `-Dlive` acceptance run. ## Acceptance (design complete when implemented) -- [ ] `nxdns run --config=` on a DB with fetched blocklists (any boot +- [x] `nxdns run --config=` 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 `.list`/`.wild` files are identical before and after. Proven - once with `-Dlive` against a real source. -- [ ] Reconciling an unchanged exported config twice yields a byte-identical + 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. -- [ ] Removing (and renaming) a group that observed clients were assigned to +- [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`. -- [ ] A file with neither `password` nor `password_hash` leaves the stored +- [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. -- [ ] `run --config=` with a missing file exits 2 with the path in +- [x] `run --config=` 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=` agrees with `run` @@ -791,38 +800,56 @@ api.md/cli.md rows), and the `-Dlive` acceptance run. 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. -- [ ] `nxdns import` whose diff would delete rows fails exit 2 without + 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`). -- [ ] The shipped compose file boots a fresh container (empty volume, mounted + *(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. -- [ ] In file mode: every `config_write` route answers 403 with the + 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. -- [ ] `GET /api/settings` reports `authority` with `reconciled_at` (null in +- [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. -- [ ] `rg -n 'import\.isEmpty|content_tables|config/bootstrap|seedFromFile|config_explicit' src/` +- [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). -- [ ] Adopt-file-mode walkthrough (stop → `export` → `check --config` → add +- [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; `export` against the *running* instance refuses - with the WalPending message, as documented. -- [ ] All existing gates pass; tripped drift guards are regenerated, not - suppressed. + 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 diff --git a/src/app.zig b/src/app.zig index 4d6387c..be38010 100644 --- a/src/app.zig +++ b/src/app.zig @@ -32,7 +32,6 @@ const tls = std.crypto.tls; const api_limiter = @import("web/api_limiter.zig"); const auth = @import("web/auth.zig"); -const bootstrap = @import("config/bootstrap.zig"); const cert_store = @import("server/cert_store.zig"); const cli = @import("cli.zig"); const clients = @import("server/clients.zig"); @@ -49,6 +48,7 @@ const fetcher = @import("filter/fetcher.zig"); const forward_zones = @import("local/forward_zones.zig"); const handler = @import("server/handler.zig"); const http_util = @import("web/http_util.zig"); +const loader = @import("config/loader.zig"); const local_records = @import("local/records.zig"); const local_tables = @import("server/local_tables.zig"); const logger_mod = @import("storage/logger.zig"); @@ -60,6 +60,7 @@ const pause = @import("server/pause.zig"); const pool_mod = @import("upstream/pool.zig"); const query_sink = @import("server/query_sink.zig"); const rate_limiter = @import("server/rate_limiter.zig"); +const reconcile = @import("config/reconcile.zig"); const retention_mod = @import("storage/retention.zig"); const safe_url = @import("safe_url.zig"); const shutdown = @import("server/shutdown.zig"); @@ -95,6 +96,12 @@ pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 { const mapped = failureExitCode(err); if (mapped == cli.exit_check) { runner.err.writeAll("run `nxdns check` to see the configuration in full\n") catch {}; + // Ruling 6: after bootstrap seeding died, a fresh database fails + // validation naturally (`NoUsableUpstreams`) and the operator needs + // to be told how a database gets a configuration at all. In file + // mode they already have a file, and the diagnostics above name what + // is wrong with it. + if (args.config == null) cli.writeDbSourceHint(runner.err); } break :code mapped; }; @@ -113,70 +120,201 @@ fn failureExitCode(err: anyerror) u8 { return if (faults.isConfigFault(err)) cli.exit_check else cli.exit_runtime; } -/// First run only: the file seeds an empty database and is ignored forever -/// after. Its diagnostics are the operator's one chance to see what the file -/// said, so they are printed the way `check` and `import` print them. +/// File authority (ruling 2): read the file the operator named, validate it, and +/// converge the database onto it — on this start and on every start after it. +/// Returns the wall clock the reconcile ran at, which becomes the settings +/// envelope's `reconciled_at`. /// -/// Printed on the way out either way. A seed file can be accepted and still -/// carry warnings — a blocklist source in no group is downloaded and compiled -/// into nothing — and a warning that only appears when the start fails is a -/// warning nobody ever reads: the start it describes is the one that worked. -/// `check` reported it and `run` did not, which left the same file graded two -/// ways. +/// A missing, unreadable or invalid file fails the start. There is no fallback +/// to the database under any failure: a fallback turns a deploy typo into a +/// silently stale configuration, which is the failure mode the whole mode exists +/// to prevent. /// -/// The runner's error writer, not `std.log`: this runs before -/// `logging.install`, and one rendering of a diagnostic across `run`, `check` -/// and `import` is the point of `Diagnostics.writeAll`. +/// Diagnostics are printed on the way out either way. A file can be accepted and +/// still carry warnings — a blocklist source in no group is downloaded and +/// compiled into nothing — and a warning that only appears when the start fails +/// is a warning nobody ever reads: the start it describes is the one that +/// worked. /// -/// Flushed here rather than left to `run`'s exit flush. That writer is buffered -/// (`main` gives it 4 KiB) and `serve` does not return for as long as the -/// service runs, so a line left in the buffer reaches the operator when the +/// The runner's writers, not `std.log`: this runs before `logging.install`, and +/// one rendering of a diagnostic across `run`, `check` and `import` is the point +/// of `Diagnostics.writeAll`. +/// +/// Flushed here rather than left to `run`'s exit flush. Both writers are +/// buffered (`main` gives them 4 KiB) and `serve` does not return for as long as +/// the service runs, so a line left in the buffer reaches the operator when the /// process stops — days after the start it describes. A failure path flushes /// anyway because it returns immediately; the successful start is the one that /// needs this. -fn seedFromFile( +fn reconcileFromFile( r: cli.Runner, config_db: *db.Db, dir: std.Io.Dir, config_path: []const u8, -) bootstrap.Error!bootstrap.Outcome { +) !i64 { + return reconcileFromFileAt( + r, + config_db, + dir, + config_path, + std.Io.Clock.real.now(r.io).toSeconds(), + ); +} + +/// `pass_now` is what the engine stamps into the runtime columns of the rows it +/// inserts (`first_seen`, `last_seen`, `created_at`), and it is deliberately not +/// the value this returns. +/// +/// The two clocks answer different questions, and conflating them was a real +/// defect: `reconciled_at` means "this process loaded the file at T" and is +/// compared against the file's mtime to detect a restart-pending state +/// (ruling 7). A stamp taken *before* the read makes a file written during the +/// read look newer than the process that loaded it — a false "restart pending" +/// in the UI for a file that is fully applied. So this returns a clock read +/// taken immediately after the commit, and `pass_now` never leaves the engine. +/// +/// Split from `reconcileFromFile` so the two are separable in a test: pin +/// `pass_now` and the returned stamp must still be the real clock. +fn reconcileFromFileAt( + r: cli.Runner, + config_db: *db.Db, + dir: std.Io.Dir, + config_path: []const u8, + pass_now: i64, +) !i64 { + var arena_state: std.heap.ArenaAllocator = .init(r.gpa); + defer arena_state.deinit(); + var diags: validate.Diagnostics = .init(r.gpa); defer diags.deinit(); - const result = bootstrap.bootstrap(r.io, r.gpa, config_db, dir, config_path, &diags); + const result = applyManagedFile(r, config_db, dir, config_path, arena_state.allocator(), &diags, pass_now); - // Neither discard is an oversight, and the two answer different questions. - // - // On a rejected seed file, `result` is returned untouched: the operator gets - // the reason the start failed, never a writer error standing in front of it. - // A broken stderr is not why the configuration was refused. - // - // On a seed that worked, a failure here does not stop the start. The trade - // is one lost warning line against a household with no name resolution, and - // `run` before this point is the only stretch of this program where an - // output failure could take DNS down at all — ruling 4 already says nothing - // after it is fatal. Nor could the failure be reported: this writer *is* the - // error channel, and `logging.install` has not run yet, so `std.log` resolves - // to the same stderr a diagnostic about it would have to travel down. - // - // It is not lost from the process either. A failed drain consumes nothing, - // so whatever the buffer held it still holds — that half is observed, in - // "a broken error writer does not stop a first start that succeeded" below, - // which reads the retained warning back out of the same writer. - // - // What happens to those bytes afterwards is derived, not watched, and is - // labelled so deliberately. `Io.Writer.defaultFlush` drains while `end != 0` - // and `run`'s exit flush maps a failure to exit 1, so a stderr still broken - // at shutdown should carry the condition out in the exit code, and one that - // recovered should deliver the line late. No test drives `run` that far. - // Two limits come with the derivation: an empty buffer flushes clean and - // reports nothing at all, and a failure that recovers ends at exit 0 with a - // line the operator reads days after the start it describes. + // Both discards are deliberate, and they answer different questions. On a + // rejected file the operator gets the reason the start failed, never a + // writer error standing in front of it — a broken stderr is not why the + // configuration was refused. On a file that applied, a failure here does not + // stop the start: the trade is one lost warning line against a household + // with no name resolution, and this writer *is* the error channel, so the + // failure has nowhere to be reported anyway. diags.writeAll(r.err) catch {}; r.err.flush() catch {}; + return result; } +/// Returns the moment the transaction committed, which is what the settings +/// envelope reports as `reconciled_at`. +fn applyManagedFile( + r: cli.Runner, + config_db: *db.Db, + dir: std.Io.Dir, + config_path: []const u8, + arena: Allocator, + diags: *validate.Diagnostics, + now: i64, +) !i64 { + const cfg = try loader.load(r.io, arena, dir, config_path, diags); + try validate.validate(cfg, diags); + + // The keys this pass wrote, never their values (ruling 8). Duplicated into + // `gpa` by the engine, so this frame frees them. + var changed: std.ArrayList([]const u8) = .empty; + defer { + for (changed.items) |key| r.gpa.free(key); + changed.deinit(r.gpa); + } + + var pass = reconcile.begin(r.io, r.gpa, config_db, cfg, now, .{ + .changed_settings = &changed, + }) catch |err| { + // `begin` has already rolled its own transaction back. What the operator + // needs is the cause: a full SD card must read as "disk", not as a bare + // exit 1, so the SQLite condition is named. + reportReconcileFailure(r, config_db, config_path, err); + return err; + }; + errdefer pass.rollback(); + + pass.commit() catch |err| { + reportReconcileFailure(r, config_db, config_path, err); + return err; + }; + + // Read here and nowhere earlier: the file is loaded once this line runs, and + // not one statement before it. + const reconciled_at = std.Io.Clock.real.now(r.io).toSeconds(); + + printSummary(r, config_path, pass.summary, changed.items) catch {}; + return reconciled_at; +} + +/// SQLite conditions an operator acts on differently. `@errorName` alone would +/// say `Full`, which is not a word anyone can search for; the primary result +/// code's own name is. +fn sqliteCodeName(err: anyerror) ?[]const u8 { + return switch (err) { + error.Full => "SQLITE_FULL", + error.Busy => "SQLITE_BUSY", + error.IoErr => "SQLITE_IOERR", + error.ReadOnly => "SQLITE_READONLY", + error.Corrupt => "SQLITE_CORRUPT", + error.Constraint => "SQLITE_CONSTRAINT", + else => null, + }; +} + +fn reportReconcileFailure(r: cli.Runner, config_db: *db.Db, config_path: []const u8, err: anyerror) void { + var buf: [256]u8 = undefined; + const detail = config_db.lastError(&buf); + const code = sqliteCodeName(err) orelse @errorName(err); + r.err.print("reconciling '{s}' failed: {s}: {s}\n", .{ config_path, code, detail }) catch {}; + r.err.flush() catch {}; +} + +/// What that restart changed, without opening sqlite (ruling 8): per-table +/// counts, the settings keys that moved — never their values — and an +/// authentication change, which is never a silent line item in a count. +fn printSummary( + r: cli.Runner, + config_path: []const u8, + summary: reconcile.Summary, + changed_settings: []const []const u8, +) !void { + try r.out.print("reconciled '{s}':", .{config_path}); + if (summary.isNoOp()) { + try r.out.writeAll(" no changes\n"); + } else { + inline for (@typeInfo(reconcile.Summary).@"struct".fields) |field| { + if (field.type == reconcile.TableCounts) { + const counts = @field(summary, field.name); + if (counts.total() != 0) { + try r.out.print(" {s} +{d} ~{d} -{d};", .{ + field.name, + counts.inserted, + counts.updated, + counts.deleted, + }); + } + } + } + try r.out.writeAll("\n"); + + if (changed_settings.len != 0) { + try r.out.writeAll("settings keys changed:"); + for (changed_settings) |key| try r.out.print(" {s}", .{key}); + try r.out.writeAll("\n"); + } + switch (summary.auth_transition) { + .none => {}, + .enabled => try r.out.writeAll("web authentication is now enabled\n"), + .disabled => try r.out.writeAll("web authentication is now disabled\n"), + .rotated => try r.out.writeAll("the web password changed\n"), + } + } + try r.out.flush(); +} + fn serve(r: cli.Runner, args: cli.RunArgs) !u8 { const io = r.io; const gpa = r.gpa; @@ -193,7 +331,15 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 { defer config_db.close(); _ = try migrations.migrate(&config_db); - _ = try seedFromFile(r, &config_db, std.Io.Dir.cwd(), paths.config); + // Ruling 1: the presence of `--config` is the whole authority decision. With + // it, the file is the sole declarative source and the database is converged + // onto it here, before anything reads the database. Without it the database + // is authority and this step does not exist — a file on disk that no flag + // names changes nothing. + const reconciled_at: ?i64 = if (args.config) |config_path| + try reconcileFromFile(r, &config_db, std.Io.Dir.cwd(), config_path) + else + null; // Every string in `cfg` points into this arena, and the pool's endpoints, // the handler's records and the monitor's paths all keep such strings. It @@ -203,6 +349,17 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 { defer arena_state.deinit(); const arena = arena_state.allocator(); + // The web layer reads the managed path out of `WebState` for the whole life + // of the process, so it takes a copy from the arena that outlives it rather + // than borrowing `argv`. + const authority: web_server.Authority = if (args.config) |config_path| + .{ .managed_file = try arena.dupe(u8, config_path) } + else + .database; + + // The database stays the runtime substrate and the effective-config read + // path in both modes: in file mode the reconcile above has just made it + // agree with the file. const cfg = try config_export.readConfig(&config_db, arena); // From here on `std.log` goes wherever the operator asked. Before this call @@ -472,7 +629,9 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 { if (cfg.web.enabled) web_state = .{ .gpa = gpa, .web = cfg.web, - .live_hash = .init(cfg.web.password_hash), + .authority = authority, + .reconciled_at = reconciled_at, + .live_hash = .init(cfg.web.password_hash orelse ""), .handler = &h, .pause = &paused, .tracker = &tracker, @@ -602,7 +761,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 { // this box exists for — keeps serving. if (cfg.web.enabled) try group.concurrent(io, web_server.serve, .{ &web_state, io }); - logStartup(io, &manager, upstreams.active().len, .{ + logStartup(io, authority, &manager, upstreams.active().len, .{ .udp6 = if (udp6) |*s| s.boundAddress() else null, .udp4 = if (udp4) |*s| s.boundAddress() else null, .tcp6 = if (tcp6) |*s| s.boundAddress() else null, @@ -1005,8 +1164,8 @@ test "run, check and import agree on a seed file with no default group" { \\} ; - // `run`: `serve` seeds through `bootstrap`, which is a wrapper over this - // exact call, so this is the error `run` classifies. + // `run --config`: `serve` validates through this exact call before it + // reconciles, so this is the error `run` classifies. var database = try db.Db.open(":memory:", .{ .mode = .memory }); defer database.close(); try db.applyPragmas(&database, .{}); @@ -1082,11 +1241,11 @@ test "a configuration whose blocklist source is in no group imports and checks c try std.testing.expectEqual(@as(usize, 1), check_diags.warningCount()); } -test "a first start that seeds from a file prints the warnings the file earned" { - // D5, second half. The seed file is read once in the life of a database, so - // a warning it earns is printed on that start or never. `run` printed - // diagnostics only when the file was rejected, which made a successful first - // start the one place the finding could not surface. +test "a start in file mode prints the warnings the file earned" { + // D5, second half. `run` printed diagnostics only when the file was + // rejected, which made a successful start the one place a finding could not + // surface. Under file authority the file is read on every start, so this is + // the line an operator sees after every restart, not only the first. var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); @@ -1119,11 +1278,10 @@ test "a first start that seeds from a file prints the warnings the file earned" var err_writer = err_file.writer(io, &err_buf); const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface }; - // The file is valid, so the start succeeds and the database is seeded. - try std.testing.expectEqual( - bootstrap.Outcome.seeded, - try seedFromFile(r, &database, tmp.dir, "config.zon"), - ); + // The file is valid, so the start succeeds and the database converges onto + // it. The returned stamp is what the settings envelope reports. + const reconciled_at = try reconcileFromFile(r, &database, tmp.dir, "config.zon"); + try std.testing.expect(reconciled_at > 0); try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams")); // Nothing flushes here on purpose. In production `serve` runs from this @@ -1138,6 +1296,164 @@ test "a first start that seeds from a file prints the warnings the file earned" try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "FAIL")); } +test "run with a missing managed file exits 2 with the path, and never serves from the database" { + // Ruling 2: file mode fails closed. The database below is a perfectly good + // one — migrated, and the run would have reached the listeners on it in db + // mode — so a fallback would show up here as exit 0. + var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + const gpa = std.testing.allocator; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + var data_buf: [160]u8 = undefined; + const data_dir = try std.fmt.bufPrint(&data_buf, ".zig-cache/tmp/{s}/data", .{tmp.sub_path}); + var missing_buf: [160]u8 = undefined; + const missing = try std.fmt.bufPrint(&missing_buf, ".zig-cache/tmp/{s}/nope.zon", .{tmp.sub_path}); + + // Real buffered `File.Writer`s, not `Writer.fixed`: this asserts the + // operator received the lines, and a fixed writer's flush is a no-op that + // counts a buffered line as delivered. + var out_file = try tmp.dir.createFile(io, "stdout.txt", .{}); + defer out_file.close(io); + var err_file = try tmp.dir.createFile(io, "stderr.txt", .{}); + defer err_file.close(io); + var out_buf: [4096]u8 = undefined; + var err_buf: [4096]u8 = undefined; + var out_writer = out_file.writer(io, &out_buf); + var err_writer = err_file.writer(io, &err_buf); + const r: cli.Runner = .{ + .io = io, + .gpa = gpa, + .out = &out_writer.interface, + .err = &err_writer.interface, + }; + + try std.testing.expectEqual(cli.exit_check, run(r, .{ + .paths = .{ .data_dir = data_dir }, + .config = missing, + })); + + const printed = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192)); + defer gpa.free(printed); + // The path is in the message: an error name alone tells the operator nothing + // about which file the deploy got wrong. + try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, missing)); + try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "no such file")); + try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "nxdns check")); + // The db-mode remediation hint belongs to db mode: in file mode the operator + // has a file, and the diagnostic above says what is wrong with it. + try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "make a file the source of truth")); +} + +test "reconciled_at is stamped after the commit, not from the clock the pass wrote with" { + // Ruling 7: `reconciled_at` means "this process loaded the file at T", and + // the UI compares it against the file's mtime to say whether a restart is + // pending. A stamp taken before the read makes a file written while the read + // ran look newer than the process that loaded it — a restart-pending banner + // over a configuration that is fully applied. + // + // The pass clock is pinned to 1970 here, which the engine really does use: + // the inserted client below carries it. If the two were one value, the + // returned stamp would be 1970 too. + var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + const gpa = std.testing.allocator; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = + \\.{ + \\ .groups = .{ .{ .name = "default" } }, + \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, + \\ .clients = .{ .{ .ip = "192.168.1.5", .name = "tablet" } }, + \\} + }); + + var database = try db.Db.open(":memory:", .{ .mode = .memory }); + defer database.close(); + try db.applyPragmas(&database, .{}); + _ = try migrations.migrate(&database); + + var out_buf: [4096]u8 = undefined; + var err_buf: [1024]u8 = undefined; + var out: Writer = .fixed(&out_buf); + var err: Writer = .fixed(&err_buf); + const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err }; + + const pass_now: i64 = 42; + const before = std.Io.Clock.real.now(io).toSeconds(); + const reconciled_at = try reconcileFromFileAt(r, &database, tmp.dir, "config.zon", pass_now); + + // The pinned clock reached the engine, so the two values really are separate + // inputs rather than the same read twice. + try std.testing.expectEqual(pass_now, try database.queryInt( + "SELECT first_seen FROM clients WHERE ip = '192.168.1.5'", + )); + try std.testing.expect(reconciled_at != pass_now); + try std.testing.expect(reconciled_at >= before); +} + +test "the startup summary reports what the reconcile changed, then that nothing changed" { + // Ruling 8: the answer to "what did that restart change" without opening + // sqlite. Also ruling 5 from the operator's side — the second start of an + // unchanged file says so. + var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + const gpa = std.testing.allocator; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = + \\.{ + \\ .groups = .{ .{ .name = "default" } }, + \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, + \\ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" }, + \\} + }); + + var database = try db.Db.open(":memory:", .{ .mode = .memory }); + defer database.close(); + try db.applyPragmas(&database, .{}); + _ = try migrations.migrate(&database); + + var out_file = try tmp.dir.createFile(io, "stdout.txt", .{}); + defer out_file.close(io); + var out_buf: [4096]u8 = undefined; + var out_writer = out_file.writer(io, &out_buf); + var err_buf: [1024]u8 = undefined; + var err: Writer = .fixed(&err_buf); + const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out_writer.interface, .err = &err }; + + _ = try reconcileFromFile(r, &database, tmp.dir, "config.zon"); + { + // Read back through the file: `serve` does not return for as long as the + // service runs, so a summary still in the buffer is a summary nobody + // reads. + const printed = try tmp.dir.readFileAlloc(io, "stdout.txt", gpa, .limited(8192)); + defer gpa.free(printed); + try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "reconciled 'config.zon':")); + try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "upstreams +1 ~0 -0")); + try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "settings keys changed:")); + try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "web.password_hash")); + try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "authentication is now enabled")); + // The keys, never the values: the hash the file set must not be echoed. + try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "$argon2id$")); + } + + try tmp.dir.writeFile(io, .{ .sub_path = "stdout.txt", .data = "" }); + _ = try reconcileFromFile(r, &database, tmp.dir, "config.zon"); + { + const printed = try tmp.dir.readFileAlloc(io, "stdout.txt", gpa, .limited(8192)); + defer gpa.free(printed); + try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "no changes")); + } +} + /// A broken stderr, in the shape `main` builds: a buffered `File.Writer`, with /// its drain switched to the mode that fails. `Writer.fixed` cannot stand in — /// its flush is `noopFlush`, so it has no failure to report and its `written()` @@ -1151,8 +1467,8 @@ fn brokenErrWriter(io: std.Io, file: std.Io.File, buffer: []u8) std.Io.File.Writ return w; } -test "a broken error writer does not replace the reason a seed file was rejected" { - // The operator has to see why seeding failed, and a broken stderr is not +test "a broken error writer does not replace the reason a managed file was rejected" { + // The operator has to see why the start failed, and a broken stderr is not // that reason. var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); defer threaded.deinit(); @@ -1187,7 +1503,7 @@ test "a broken error writer does not replace the reason a seed file was rejected try std.testing.expectError( error.MissingDefaultGroup, - seedFromFile(r, &database, tmp.dir, "config.zon"), + reconcileFromFile(r, &database, tmp.dir, "config.zon"), ); // Empty, so the writer did fail — without this the assertion above would @@ -1197,7 +1513,7 @@ test "a broken error writer does not replace the reason a seed file was rejected try std.testing.expectEqual(@as(usize, 0), printed.len); } -test "a broken error writer does not stop a first start that succeeded" { +test "a broken error writer does not stop a start whose file applied" { // The call this file makes: a DNS server for a household does not refuse to // resolve because stderr is broken. What it must not do is drop the warning // on the floor, so the second half checks the buffer still holds it. @@ -1233,10 +1549,7 @@ test "a broken error writer does not stop a first start that succeeded" { var err_writer = brokenErrWriter(io, err_file, &err_buf); const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface }; - try std.testing.expectEqual( - bootstrap.Outcome.seeded, - try seedFromFile(r, &database, tmp.dir, "config.zon"), - ); + _ = try reconcileFromFile(r, &database, tmp.dir, "config.zon"); try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams")); // Nothing reached the file, so the flush really did fail. @@ -1289,7 +1602,20 @@ fn isWildcard(addr: net.IpAddress) bool { /// One line, at info, naming what an operator needs to see in `journalctl` /// right after a restart: where it listens, how many upstreams it has, and /// whether filtering is live. -fn logStartup(io: std.Io, manager: *manager_mod.Manager, upstream_count: usize, bound: Listeners) void { +/// +/// Preceded by the authority (ruling 8), because every other question about a +/// restart — why a UI edit vanished, why a file edit did not apply — starts with +/// which of the two governs this process, and the journal is where an operator +/// looks for it. +fn logStartup( + io: std.Io, + authority: web_server.Authority, + manager: *manager_mod.Manager, + upstream_count: usize, + bound: Listeners, +) void { + log.info("authority: {f}", .{AuthorityText{ .authority = authority }}); + var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); appendBind(&w, "udp", bound.udp6); @@ -1316,6 +1642,25 @@ fn logStartup(io: std.Io, manager: *manager_mod.Manager, upstream_count: usize, } } +/// Which source governs this process, in the words the journal carries. +/// +/// A formatter rather than a rendering into a buffer of this file's own: a +/// managed path is bounded only by `Dir.max_path_bytes`, and nested bind mounts +/// make long ones ordinary, so a fixed buffer here would silently drop exactly +/// the half of the line an operator came for. Writing straight to the log sink's +/// writer leaves the one documented, counted truncation in `platform/logging.zig` +/// as the only limit. +const AuthorityText = struct { + authority: web_server.Authority, + + pub fn format(self: AuthorityText, w: *Writer) Writer.Error!void { + switch (self.authority) { + .database => try w.writeAll("database"), + .managed_file => |path| try w.print("file ({s})", .{path}), + } + } +}; + /// Silent on overflow: a truncated startup line is not worth a failure path, /// and 256 bytes hold four addresses. fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void { @@ -1323,6 +1668,34 @@ fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void { w.print(" {s} {f}", .{ which, value }) catch {}; } +test "the startup line names which source governs this process, path and all" { + // Ruling 8. Every other question about a restart starts here, so the answer + // is in the journal rather than derived from the unit file by whoever is + // reading at 2am. + const gpa = std.testing.allocator; + + var short: Writer.Allocating = .init(gpa); + defer short.deinit(); + try short.writer.print("{f}", .{AuthorityText{ .authority = .database }}); + try std.testing.expectEqualStrings("database", short.written()); + + var named: Writer.Allocating = .init(gpa); + defer named.deinit(); + try named.writer.print("{f}", .{AuthorityText{ .authority = .{ .managed_file = "/etc/nxdns/config.zon" } }}); + try std.testing.expectEqualStrings("file (/etc/nxdns/config.zon)", named.written()); + + // A path past any buffer this file could reasonably have picked. Nested bind + // mounts produce paths like this, and the path is the half of the line the + // operator came for — dropping it to keep the line short is the wrong trade. + const long_path = "/mnt/" ++ ("deeply-nested-mount/" ** 20) ++ "config.zon"; + try std.testing.expect(long_path.len > 256); + + var long: Writer.Allocating = .init(gpa); + defer long.deinit(); + try long.writer.print("{f}", .{AuthorityText{ .authority = .{ .managed_file = long_path } }}); + try std.testing.expect(std.mem.containsAtLeast(u8, long.written(), 1, long_path)); +} + const test_address = @import("platform/address.zig"); test "one maintenance pass drops the api limiter's stale buckets" { diff --git a/src/cli.zig b/src/cli.zig index 7abdff8..17f1fd5 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -22,6 +22,7 @@ const app = @import("app.zig"); const config_export = @import("config/export.zig"); const faults = @import("config/faults.zig"); const import = @import("config/import.zig"); +const loader = @import("config/loader.zig"); const model = @import("config/model.zig"); const validate = @import("config/validate.zig"); const cert_store = @import("server/cert_store.zig"); @@ -50,19 +51,31 @@ pub const querylog_db_name = "querylog.db"; pub const Paths = struct { /// PLAN §3.13. data_dir: []const u8 = "/var/lib/nxdns", - config: []const u8 = "/etc/nxdns/config.zon", }; -/// `config_explicit` records whether `--config` was given, because `check` has -/// to tell "the operator named a file" from "the default path happens to -/// exist". -pub const CheckArgs = struct { paths: Paths = .{}, config_explicit: bool = false }; +/// Milestone-20 ruling 1: `--config` has no default path, and its presence is +/// the whole of the authority decision. Null means the database is authority — +/// for `run` that is today's appliance behaviour, for `check` it is the database +/// that gets graded. A file sitting at a well-known path that no flag names +/// changes nothing. +pub const CheckArgs = struct { paths: Paths = .{}, config: ?[]const u8 = null }; pub const ExportArgs = struct { paths: Paths = .{}, out: ?[]const u8 = null }; -pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, force: bool = false }; +/// `allow_delete` is `--allow-delete`: it permits an import whose diff removes +/// declarative rows (ruling 6). +pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, allow_delete: bool = false }; + +/// `config` names the managed configuration file and, by being present at all, +/// makes that file the sole declarative source of truth: it is read, validated +/// and reconciled into the database on every start. +/// /// `web_dev` is milestone-8 ruling 24's `--web-dev `: serve the web /// interface from that directory instead of the embedded assets. -pub const RunArgs = struct { paths: Paths = .{}, web_dev: ?[]const u8 = null }; +pub const RunArgs = struct { + paths: Paths = .{}, + config: ?[]const u8 = null, + web_dev: ?[]const u8 = null, +}; pub const Command = union(enum) { run: RunArgs, @@ -176,7 +189,7 @@ fn parseRunArgs(argv: []const []const u8) ParseError!RunArgs { if (eql(flag.name, "data-dir")) { args.paths.data_dir = try flagValue(flag, argv, &i); } else if (eql(flag.name, "config")) { - args.paths.config = try flagValue(flag, argv, &i); + args.config = try flagValue(flag, argv, &i); } else if (eql(flag.name, "web-dev")) { args.web_dev = try flagValue(flag, argv, &i); } else return error.UnknownFlag; @@ -192,8 +205,7 @@ fn parseCheckArgs(argv: []const []const u8) ParseError!CheckArgs { if (eql(flag.name, "data-dir")) { args.paths.data_dir = try flagValue(flag, argv, &i); } else if (eql(flag.name, "config")) { - args.paths.config = try flagValue(flag, argv, &i); - args.config_explicit = true; + args.config = try flagValue(flag, argv, &i); } else return error.UnknownFlag; } return args; @@ -215,7 +227,7 @@ fn parseExportArgs(argv: []const []const u8) ParseError!ExportArgs { fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs { var paths: Paths = .{}; - var force = false; + var allow_delete = false; var file: ?[]const u8 = null; var i: usize = 0; @@ -227,18 +239,18 @@ fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs { }; if (eql(flag.name, "data-dir")) { paths.data_dir = try flagValue(flag, argv, &i); - } else if (eql(flag.name, "force")) { - // A boolean flag takes no value, so `--force=1` is not a spelling of - // any flag this program has. + } else if (eql(flag.name, "allow-delete")) { + // A boolean flag takes no value, so `--allow-delete=1` is not a + // spelling of any flag this program has. if (flag.attached != null) return error.UnknownFlag; - force = true; + allow_delete = true; } else return error.UnknownFlag; } return .{ .paths = paths, .file = file orelse return error.MissingArgument, - .force = force, + .allow_delete = allow_delete, }; } @@ -379,14 +391,30 @@ const usage_text = \\ \\options: \\ --data-dir DIR data directory (default /var/lib/nxdns) - \\ --config FILE configuration file (default /etc/nxdns/config.zon) + \\ --config FILE run: make FILE the sole source of configuration and + \\ reconcile the database onto it at every start; + \\ check: grade FILE instead of the database \\ --out FILE write the export to FILE instead of stdout - \\ --force let import replace a database that already has content + \\ --allow-delete let import apply a file whose diff deletes rows \\ --web-dev DIR run only: serve the web interface from DIR instead of \\ the embedded assets \\ ; +/// The one remediation line for a database that holds no usable configuration. +/// Both routes to that state print it: `run` refusing an unconfigured database +/// (`NoUsableUpstreams`) and `check` finding no `config.db` at all. +/// +/// It lives here, beside the exit-code mapping, because a Zig error carries no +/// text and `config/validate.zig` must stay blind to which source it is grading +/// — the same file validates a database reading and a managed file. +pub const db_source_hint = + "load one with `nxdns import `, or make a file the source of truth with `nxdns run --config `\n"; + +pub fn writeDbSourceHint(w: *Writer) void { + w.writeAll(db_source_hint) catch {}; +} + /// Returns nothing, so a writer failure here has nowhere to go. Every caller /// flushes afterwards and reports that failure instead. pub fn usage(w: *Writer) void { @@ -493,7 +521,7 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void { &database, std.Io.Dir.cwd(), args.file, - .{ .force = args.force }, + .{ .allow_delete = args.allow_delete }, diags, ); @@ -514,9 +542,10 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void { /// That file is the only list — this function keeps none of its own, which is /// what stops `run`, `check` and `import` drifting apart again (D1). /// -/// `error.DatabaseNotEmpty` is the one exception, and it is deliberate: it -/// reports the state of the database rather than the content of a file, so it -/// is not a configuration fault, and `import` alone decides it is exit 2. +/// `error.DestructiveImport` is the one exception, and it is deliberate: it +/// reports what the diff would do to the database rather than the content of a +/// file, so it is not a configuration fault, and `import` alone decides it is +/// exit 2. /// /// `error.OutOfMemory` is matched first, before anything else is consulted. /// Both recording paths — `validate` and import's per-line rendering of a ZON @@ -530,7 +559,7 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void { fn failureExitCode(e: anyerror, failures: usize) u8 { if (e == error.OutOfMemory) return exit_runtime; if (failures != 0) return exit_check; - if (e == error.DatabaseNotEmpty) return exit_check; + if (e == error.DestructiveImport) return exit_check; return if (faults.isConfigFault(e)) exit_check else exit_runtime; } @@ -563,28 +592,31 @@ fn checkImpl(r: Runner, args: CheckArgs, probe: bool) !u8 { // Which source was used is printed in every branch, so the answer is never // ambiguous about what it checked. - if (args.config_explicit) { - try r.out.print("checking configuration file {s}\n", .{args.paths.config}); - return checkFile(r, arena, args.paths.config, probe); + // + // Ruling 1: the invocation decides, and nothing else. The heuristic that + // used to live here — probe for `config.db`, fall back to probing + // `/etc/nxdns/config.zon`, grade whichever exists — made the answer a + // function of what happened to be on disk, which is the ambient inference + // that made seed-once bootstrap a source of documentation lies. A file no + // flag names is not graded. + if (args.config) |path| { + try r.out.print("checking configuration file {s}\n", .{path}); + return checkFile(r, arena, path, probe); } const config_db_path = try std.fs.path.joinZ(arena, &.{ args.paths.data_dir, config_db_name }); - if (try pathExists(r.io, config_db_path)) { - try r.out.print("checking database {s}\n", .{config_db_path}); - return checkDatabase(r, arena, config_db_path, probe); + if (!try pathExists(r.io, config_db_path)) { + // The deleted heuristic's "nothing to check" branch, replaced rather + // than dropped: an operator running `check` on a box that has never + // been configured gets the same exit code and one line saying what to + // do about it. + try r.out.print("no config database at {s}\n", .{config_db_path}); + try r.out.writeAll(db_source_hint); + return exit_check; } - if (try pathExists(r.io, args.paths.config)) { - try r.out.print("checking configuration file {s}\n", .{args.paths.config}); - return checkFile(r, arena, args.paths.config, probe); - } - - try r.out.print("nothing to check: no {s} in {s} and no {s}\n", .{ - config_db_name, - args.paths.data_dir, - args.paths.config, - }); - return exit_check; + try r.out.print("checking database {s}\n", .{config_db_path}); + return checkDatabase(r, arena, config_db_path, probe); } /// `check` reads `config.db` and writes nothing to it (F-c): no create, no @@ -722,52 +754,31 @@ fn pathReadable(io: std.Io, path: []const u8) std.Io.Dir.AccessError!bool { return true; } +/// Grades the file `--config` named, through `config/loader.zig` — the same +/// read and the same classification `nxdns run --config` uses. That shared +/// helper is what makes the scoped agreement of ruling 2 true: `check` reaches +/// exactly the read, size and parse faults `run` would reach, and validation +/// below is the same call on the same `Config`. +/// +/// D4: a named file that is missing or unreadable is the same operator-fixable +/// condition as one that fails to parse, so it is reported as a finding rather +/// than escaping as a runtime failure. fn checkFile(r: Runner, arena: Allocator, path: []const u8, probe: bool) !u8 { - const source = std.Io.Dir.cwd().readFileAllocOptions( - r.io, - path, - arena, - .limited(import.max_config_bytes), - .of(u8), - 0, - ) catch |e| switch (e) { - error.StreamTooLong => { - try r.out.print("FAIL {s}: larger than {d} bytes\n", .{ path, import.max_config_bytes }); - return exit_check; - }, - // D4: a named file that is missing or unreadable is the same - // operator-fixable condition as one that fails to parse, so it is - // reported as a finding rather than escaping as a runtime failure. The - // implicit path already exits 2 when it finds nothing to check; naming - // the file must not change the code. - error.FileNotFound => { - try r.out.print("FAIL {s}: no such file\n", .{path}); - return exit_check; - }, - error.AccessDenied, error.PermissionDenied => { - try r.out.print("FAIL {s}: not readable\n", .{path}); - return exit_check; - }, - else => |other| return other, - }; + var diags: validate.Diagnostics = .init(r.gpa); + defer diags.deinit(); - // Arena-owned and never handed to `std.zon.parse.free`; see the rule and its - // `parse.zig:874` citation in `config/import.zig`. - var zon_diag: std.zon.parse.Diagnostics = .{}; - const cfg = std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) { + const cfg = loader.load(r.io, arena, std.Io.Dir.cwd(), path, &diags) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, - // The rendering carries the line and column, which is the whole value of - // running `check` against a file the operator just edited. It is - // multi-line, and `check` promises one line per problem, so it goes - // through the same `Diagnostics` channel `nxdns import` uses rather than - // into one `FAIL` record with newlines inside it. - error.ParseZon => { - var diags: validate.Diagnostics = .init(r.gpa); - defer diags.deinit(); - try import.reportParseFailure(&diags, &zon_diag); + error.ManagedConfigUnreadable, error.ConfigTooLarge, error.ParseZon => { + // One line per problem, the promise the rest of `check` keeps: a + // multi-line ZON rendering is several problems, not one `FAIL` + // record with newlines inside it. try diags.writeAll(r.out); return exit_check; }, + // A box fault — fd exhaustion, an I/O error — is not a verdict on the + // configuration and keeps its own name at exit 1. + else => |other| return other, }; return checkConfig(r, cfg, probe); @@ -1013,17 +1024,22 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize { const testing = std.testing; -test "parseArgs accepts run with no flags" { +test "run without --config selects database authority" { + // Ruling 1: authority is the invocation. No default path, so nothing on + // disk can make a bare `run` read a file. const command = try parseArgs(&.{"run"}); try testing.expectEqualStrings("/var/lib/nxdns", command.run.paths.data_dir); - try testing.expectEqualStrings("/etc/nxdns/config.zon", command.run.paths.config); + try testing.expectEqual(@as(?[]const u8, null), command.run.config); try testing.expectEqual(@as(?[]const u8, null), command.run.web_dev); } -test "parseArgs accepts run with --data-dir and --config" { +test "run --config selects file authority and the path lands in the run args" { + const attached = try parseArgs(&.{ "run", "--config=/etc/nxdns/config.zon" }); + try testing.expectEqualStrings("/etc/nxdns/config.zon", attached.run.config.?); + const command = try parseArgs(&.{ "run", "--data-dir", "/srv/nx", "--config", "/tmp/c.zon" }); try testing.expectEqualStrings("/srv/nx", command.run.paths.data_dir); - try testing.expectEqualStrings("/tmp/c.zon", command.run.paths.config); + try testing.expectEqualStrings("/tmp/c.zon", command.run.config.?); } test "parseArgs accepts run with --web-dev in both spellings" { @@ -1049,13 +1065,12 @@ test "parseArgs accepts --data-dir with and without an equals sign" { try testing.expectEqualStrings("/srv/nx", separate.check.paths.data_dir); } -test "parseArgs records whether check was given an explicit --config" { +test "bare check grades the database and check --config grades the file" { const implicit = try parseArgs(&.{"check"}); - try testing.expect(!implicit.check.config_explicit); + try testing.expectEqual(@as(?[]const u8, null), implicit.check.config); const explicit = try parseArgs(&.{ "check", "--config=/tmp/c.zon" }); - try testing.expect(explicit.check.config_explicit); - try testing.expectEqualStrings("/tmp/c.zon", explicit.check.paths.config); + try testing.expectEqualStrings("/tmp/c.zon", explicit.check.config.?); } test "parseArgs accepts export with --out" { @@ -1066,17 +1081,21 @@ test "parseArgs accepts export with --out" { try testing.expectEqual(@as(?[]const u8, null), bare.export_.out); } -test "parseArgs accepts import with a file, --force and --data-dir" { - const command = try parseArgs(&.{ "import", "c.zon", "--force", "--data-dir=/srv/nx" }); +test "parseArgs accepts import with a file, --allow-delete and --data-dir" { + const command = try parseArgs(&.{ "import", "c.zon", "--allow-delete", "--data-dir=/srv/nx" }); try testing.expectEqualStrings("c.zon", command.import_.file); - try testing.expect(command.import_.force); + try testing.expect(command.import_.allow_delete); try testing.expectEqualStrings("/srv/nx", command.import_.paths.data_dir); } test "parseArgs accepts import with the file after the flags" { const command = try parseArgs(&.{ "import", "--data-dir", "/srv/nx", "c.zon" }); try testing.expectEqualStrings("c.zon", command.import_.file); - try testing.expect(!command.import_.force); + try testing.expect(!command.import_.allow_delete); +} + +test "the renamed import flag replaces --force rather than joining it" { + try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--force" })); } test "parseArgs accepts version" { @@ -1091,7 +1110,7 @@ test "parseArgs accepts help, --help and -h" { test "parseArgs rejects import without a file" { try testing.expectError(error.MissingArgument, parseArgs(&.{"import"})); - try testing.expectError(error.MissingArgument, parseArgs(&.{ "import", "--force" })); + try testing.expectError(error.MissingArgument, parseArgs(&.{ "import", "--allow-delete" })); } test "parseArgs rejects --out without a value" { @@ -1101,7 +1120,7 @@ test "parseArgs rejects --out without a value" { test "parseArgs rejects an unknown flag" { try testing.expectError(error.UnknownFlag, parseArgs(&.{ "check", "--nope" })); - try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--force=1" })); + try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--allow-delete=1" })); } test "parseArgs rejects an unknown command" { @@ -1134,6 +1153,17 @@ test "usage_text lists every command in command_names" { } } +test "usage_text names the flags this milestone renamed and describes --config" { + // The flag an operator reaches for is the one the help text names. `--force` + // is gone rather than aliased (greenfield rules), and `--config` no longer + // advertises a default path, because there is none: its presence is the + // whole authority decision. + try testing.expect(std.mem.containsAtLeast(u8, usage_text, 1, " --allow-delete ")); + try testing.expectEqual(@as(usize, 0), std.mem.count(u8, usage_text, "--force")); + try testing.expectEqual(@as(usize, 0), std.mem.count(u8, usage_text, "default /etc/nxdns/config.zon")); + try testing.expect(std.mem.containsAtLeast(u8, usage_text, 1, "sole source of configuration")); +} + test "usage writes non-empty text" { var out: Writer.Allocating = .init(testing.allocator); defer out.deinit(); @@ -1246,7 +1276,7 @@ test "runUsageError names the fault and prints the usage text" { } test "failureExitCode separates a fixable configuration from a runtime failure" { - try testing.expectEqual(exit_check, failureExitCode(error.DatabaseNotEmpty, 0)); + try testing.expectEqual(exit_check, failureExitCode(error.DestructiveImport, 0)); try testing.expectEqual(exit_check, failureExitCode(error.ParseZon, 0)); try testing.expectEqual(exit_check, failureExitCode(error.NoUpstreams, 1)); try testing.expectEqual(exit_runtime, failureExitCode(error.IoErr, 0)); @@ -1302,9 +1332,13 @@ test "failureExitCode keeps no list of its own and classifies through config/fau } // The one config-shaped exit 2 `cli` still decides for itself: it reports - // the state of the database, not the content of a file. - try testing.expect(!faults.isConfigFault(error.DatabaseNotEmpty)); - try testing.expectEqual(exit_check, failureExitCode(error.DatabaseNotEmpty, 0)); + // what the diff would do to the database, not the content of a file. + try testing.expect(!faults.isConfigFault(error.DestructiveImport)); + try testing.expectEqual(exit_check, failureExitCode(error.DestructiveImport, 0)); + + // The managed file goes the other way: `config/loader.zig` converts the + // path class, so the classification — not this function — carries it. + try testing.expectEqual(exit_check, failureExitCode(error.ManagedConfigUnreadable, 0)); } const fixtures = @import("test_fixtures"); @@ -1457,10 +1491,7 @@ test "check --config naming a missing file is a reported failure at exit 2" { defer captured.deinit(); const r = captured.runner(); - const code = runCheck(r, .{ - .paths = .{ .config = env.missing_path }, - .config_explicit = true, - }, false); + const code = runCheck(r, .{ .config = env.missing_path }, false); try testing.expectEqual(exit_check, code); const text = captured.out.written(); @@ -1469,6 +1500,37 @@ test "check --config naming a missing file is a reported failure at exit 2" { try testing.expectEqualStrings("", captured.err.written()); } +test "bare check with no config database exits 2 and says how to make one" { + // The deleted heuristic's "nothing to check" branch, replaced. The valid + // file sitting in the same directory is the other half of ruling 1: bare + // `check` grades the database, and a file no flag named is not consulted — + // if it were, this run would print "OK: no problems found" instead. + var env: CheckEnv = undefined; + try env.init(); + defer env.deinit(); + + var captured: Captured = .init(testing.allocator); + defer captured.deinit(); + const r = captured.runner(); + + try env.tmp.dir.writeFile(r.io, .{ .sub_path = "config.zon", .data = + \\.{ + \\ .groups = .{ .{ .name = "default" } }, + \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, + \\} + }); + + const code = runCheck(r, .{ .paths = .{ .data_dir = env.data_dir } }, false); + + try testing.expectEqual(exit_check, code); + const text = captured.out.written(); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "no config database at ")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, config_db_name)); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, db_source_hint)); + try testing.expectEqual(@as(usize, 0), std.mem.count(u8, text, "OK")); + try testing.expectEqualStrings("", captured.err.written()); +} + test "check renders a multi-line ZON failure as one FAIL line per message" { // The rendering used to go inline into a single `FAIL` record, which put // newlines mid-line and broke the one-line-per-problem promise the rest of @@ -1486,10 +1548,7 @@ test "check renders a multi-line ZON failure as one FAIL line per message" { var path_buf: [160]u8 = undefined; const config_path = try env.path(&path_buf, "config.zon"); - const code = runCheck(r, .{ - .paths = .{ .config = config_path }, - .config_explicit = true, - }, false); + const code = runCheck(r, .{ .config = config_path }, false); try testing.expectEqual(exit_check, code); const text = captured.out.written(); diff --git a/src/config/bootstrap.zig b/src/config/bootstrap.zig deleted file mode 100644 index 0ed3591..0000000 --- a/src/config/bootstrap.zig +++ /dev/null @@ -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", - )); -} diff --git a/src/config/export.zig b/src/config/export.zig index 6fcb486..1e46ac2 100644 --- a/src/config/export.zig +++ b/src/config/export.zig @@ -34,7 +34,7 @@ pub const Error = ReadError || Writer.Error || const header = \\// nxdns configuration - \\// generated by `nxdns export` — the database is the source of truth + \\// generated by `nxdns export` from the running configuration \\ ; @@ -64,11 +64,13 @@ pub fn readConfig(database: *db.Db, arena: Allocator) ReadError!model.Config { cfg.local_records = (try local_repo.listLocalRecords(database, arena)).items; cfg.forward_zones = (try local_repo.listForwardZones(database, arena)).items; - // `web.password` is operator input and is never stored; the exported file - // always carries an empty one. This is exactly what makes the round trip - // stable: re-importing takes the "password is empty" branch and stores the - // same hash. - cfg.web.password = ""; + // `web.password` is operator input and is never stored, so the exported + // file always states it as absent. Absent rather than `""`: a present empty + // password is refused by `validate` (ruling 4), so exporting one would make + // every export fail its own rules. It is also what makes the round trip + // stable — re-applying the file takes the "password_hash written verbatim" + // branch and stores the same hash. + cfg.web.password = null; return cfg; } @@ -250,7 +252,7 @@ test "readConfig, writeConfig, import and readConfig again produce an equal conf try testing.expectEqual(a.dns.port, b.dns.port); try testing.expectEqual(a.logging.level, b.logging.level); - try testing.expectEqualStrings(a.web.password_hash, b.web.password_hash); + try testing.expectEqualStrings(a.web.password_hash.?, b.web.password_hash.?); try testing.expectEqual(a.groups.len, b.groups.len); try testing.expectEqual(a.upstreams.len, b.upstreams.len); for (a.upstreams, b.upstreams) |left, right| { @@ -303,12 +305,57 @@ test "an exported password_hash survives a re-import unchanged" { .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, .web = .{ .password = "correct horse battery staple" }, }; - try import.applyToDb(io, gpa, &database, cfg, 42, .{}); + var diags: validate.Diagnostics = .init(gpa); + defer diags.deinit(); + try import.apply(io, gpa, &database, cfg, 42, .{}, &diags); var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const exported = try readConfig(&database, arena_state.allocator()); - try testing.expectEqualStrings("", exported.web.password); - try testing.expect(std.mem.startsWith(u8, exported.web.password_hash, "$argon2id$")); + try testing.expectEqual(@as(?[]const u8, null), exported.web.password); + try testing.expect(std.mem.startsWith(u8, exported.web.password_hash.?, "$argon2id$")); +} + +test "the exported password form is the one validate accepts" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + const gpa = testing.allocator; + + var database = try openMigrated(); + defer database.close(); + var apply_diags: validate.Diagnostics = .init(gpa); + defer apply_diags.deinit(); + try import.apply(io, gpa, &database, .{ + .groups = &.{.{ .name = "default" }}, + .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, + .web = .{ .password = "correct horse battery staple" }, + }, 42, .{}, &apply_diags); + + var out: Writer.Allocating = .init(gpa); + defer out.deinit(); + try writeToWriter(gpa, &database, &out.writer); + + // The literal form matters: an export carrying `password = ""` beside a + // stored hash would trip `EmptyWebPassword` on the way back in, so export + // would produce a file its own validator refuses. + try testing.expect(std.mem.indexOf(u8, out.written(), ".password = null,") != null); + + const source = try gpa.dupeZ(u8, out.written()); + defer gpa.free(source); + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const reparsed = try std.zon.parse.fromSliceAlloc( + model.Config, + arena_state.allocator(), + source, + null, + .{}, + ); + var diags: validate.Diagnostics = .init(gpa); + defer diags.deinit(); + try validate.validate(reparsed, &diags); + try testing.expectEqual(@as(?[]const u8, null), reparsed.web.password); + try testing.expect(std.mem.startsWith(u8, reparsed.web.password_hash.?, "$argon2id$")); } diff --git a/src/config/faults.zig b/src/config/faults.zig index dbec8a1..bd6efb4 100644 --- a/src/config/faults.zig +++ b/src/config/faults.zig @@ -13,17 +13,26 @@ const validate = @import("validate.zig"); /// `ValidateError` enters as a whole set rather than variant by variant, so a /// variant added to the validator cannot silently fall through to exit 1. The -/// four extras are the configuration faults raised outside the validator: the -/// ZON reader (`ParseZon`), the seed-file size limit (`ConfigTooLarge`), the -/// composition root's upstream build (`NoUsableUpstreams`) and its certificate -/// load (`BadCertificate`). +/// five extras are the configuration faults raised outside the validator: the +/// ZON reader (`ParseZon`), the file size limit (`ConfigTooLarge`), the managed +/// file the operator named and this process cannot open +/// (`ManagedConfigUnreadable`, milestone-20 ruling 2), the composition root's +/// upstream build (`NoUsableUpstreams`) and its certificate load +/// (`BadCertificate`). /// -/// Not here on purpose: `error.DatabaseNotEmpty`, which reports the state of -/// the database rather than the content of a file, and is the one config-shaped -/// exit 2 `cli` decides for itself. +/// `ManagedConfigUnreadable` is the only place a missing or unreadable path is a +/// configuration fault, and it is deliberately not `FileNotFound` itself: the +/// operator named that path on the command line, so it is theirs to fix, while a +/// missing file anywhere else stays a runtime failure. `config/loader.zig` owns +/// the conversion and the closed set of open errors that qualify. +/// +/// Not here on purpose: `error.DestructiveImport`, which reports what an import +/// would do to the database rather than the content of a file, and is the one +/// config-shaped exit 2 `cli` decides for itself. const ConfigFault = validate.ValidateError || error{ ParseZon, ConfigTooLarge, + ManagedConfigUnreadable, NoUsableUpstreams, BadCertificate, }; @@ -94,6 +103,14 @@ test "the faults raised outside the validator are configuration faults" { try testing.expect(isConfigFault(error.BadCertificate)); } +test "a managed file the operator named and this process cannot open is exit 2" { + // Ruling 2. The general rule below still holds — a bare `FileNotFound` is a + // runtime failure — and this is the one converted form, produced only by + // `config/loader.zig` for a path `--config` named. + try testing.expect(isConfigFault(error.ManagedConfigUnreadable)); + try testing.expect(!isConfigFault(error.FileNotFound)); +} + test "the seed-file errors that used to exit 1 from run are configuration faults" { // D1 verbatim: these three reached `run` from a rejected seed file and were // classified as runtime failures. @@ -107,9 +124,9 @@ test "a runtime failure is not a configuration fault" { try testing.expect(!isConfigFault(error.AccessDenied)); try testing.expect(!isConfigFault(error.FileNotFound)); try testing.expect(!isConfigFault(error.AddressInUse)); - // A state conflict, not a bad file: `import` refuses to overwrite a - // configured database and decides that exit code itself. - try testing.expect(!isConfigFault(error.DatabaseNotEmpty)); + // A verdict on the diff, not on the file: `import` refuses a run that would + // delete rows and decides that exit code itself. + try testing.expect(!isConfigFault(error.DestructiveImport)); // Only ever a warning, so it never reaches an exit code by this route. try testing.expect(!isConfigFault(error.SourceInNoGroup)); } diff --git a/src/config/import.zig b/src/config/import.zig index 3545b9b..170790e 100644 --- a/src/config/import.zig +++ b/src/config/import.zig @@ -1,18 +1,28 @@ -//! `nxdns import`: a ZON file becomes the whole configuration of `config.db`. +//! `nxdns import`: a ZON file becomes the configuration of `config.db`. //! -//! Configuration, not content: the `hand_edited = 0` client rows the DNS path -//! materialises from live traffic are runtime state, they are absent from an -//! export, and an import carries them across rather than deleting them. +//! A thin wrapper over `config/reconcile.zig` since milestone 20 (ruling 6). +//! Everything about *what* an apply does to the database — identity matching, +//! runtime-state preservation, the settings sweep, the password rule — belongs +//! to the engine and is documented there. What lives here is the file end of the +//! command: read, parse, validate, and the one policy the engine does not carry. //! -//! `clients.first_seen` and `clients.last_seen` are runtime state on every client -//! row, configured ones included, and the config model carries neither. So an -//! address the database already knew keeps both across an import, and only an -//! address it has never seen takes the import's clock. +//! **The policy is the diff gate.** Reconcile never deletes runtime state, but a +//! row the file stops declaring is deleted, so `nxdns import ./wrong.zon` +//! against a configured database would still remove every group, rule, upstream +//! and source that file omits. So an import whose diff deletes anything is +//! refused unless `--allow-delete` says otherwise. It replaces the emptiness +//! guard this file used to carry and is strictly better: an additive or +//! edit-only import needs no flag at all, and the flag now names what it +//! permits. +//! +//! The decision happens *inside* the `BEGIN IMMEDIATE` that produced the counts, +//! which is why `reconcile.begin` hands back an open transaction rather than +//! committing on its own. Checking after a commit would be a verdict on a +//! database the check had already changed. //! //! The order is the specification. Nothing reaches the database until the file -//! has been read, parsed and validated, and every write happens inside one -//! `BEGIN IMMEDIATE` transaction, so a failed import leaves the database -//! byte-for-byte as it was. +//! has been read, parsed and validated, and every write happens inside that one +//! transaction, so a refused import leaves the database byte-for-byte as it was. //! //! No filesystem write happens anywhere in this file: the input is opened //! read-only and the database is SQLite's business. @@ -21,76 +31,20 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const db = @import("../storage/db.zig"); -const config_schema = @import("../storage/config_schema.zig"); -const context = @import("../storage/repositories/context.zig"); -const clients_repo = @import("../storage/repositories/clients_repo.zig"); -const groups_repo = @import("../storage/repositories/groups_repo.zig"); -const local_repo = @import("../storage/repositories/local_repo.zig"); -const rules_repo = @import("../storage/repositories/rules_repo.zig"); -const settings_repo = @import("../storage/repositories/settings_repo.zig"); -const sources_repo = @import("../storage/repositories/sources_repo.zig"); -const upstreams_repo = @import("../storage/repositories/upstreams_repo.zig"); -const address = @import("../platform/address.zig"); +const loader = @import("loader.zig"); const model = @import("model.zig"); +const reconcile = @import("reconcile.zig"); const validate = @import("validate.zig"); -const log = std.log.scoped(.config_import); +pub const Options = struct { + /// `--allow-delete`. Permits an apply whose diff removes declarative rows. + allow_delete: bool = false, +}; -pub const Options = struct { force: bool = false }; +pub const Error = db.Error || validate.ValidateError || reconcile.Error || loader.ReadError || + error{ DestructiveImport, ConfigTooLarge, ParseZon }; -pub const Error = db.Error || validate.ValidateError || std.Io.Dir.ReadFileAllocError || - error{ DatabaseNotEmpty, ConfigTooLarge, ParseZon, PasswordAndHashBothSet }; - -pub const max_config_bytes = 4 * 1024 * 1024; - -/// Holds any PHC-encoded argon2id string comfortably. -const hash_buf_len = 256; - -/// The canonical text of an IPv6 prefix, the longest value canonicalised here. -const canonical_buf_len = 64; - -// --------------------------------------------------------------------------- -// emptiness -// --------------------------------------------------------------------------- - -/// A database is "never configured" when the migrations have run and the -/// operator has added nothing. The migrations themselves create -/// `schema_version` and seed `groups(1, 'default')`, so "no rows anywhere" is -/// the wrong test. -/// -/// True iff no table in `config_schema.content_tables` holds a row the operator -/// put there, `groups` holds exactly one row, and that row is the seeded -/// `(1, 'default', 0)`. -/// -/// `clients` is the one table a row can reach without an operator: the DNS path -/// materialises `hand_edited = 0` rows straight from live traffic (PLAN §7.2). -/// Counting those made emptiness a function of traffic — a server that had -/// answered a single query silently ignored the seed file its operator dropped -/// next to it. So only `hand_edited = 1` rows count, which is the predicate -/// `clients_repo.listClients` already exports by: this database is empty exactly -/// when its export is empty. -pub fn isEmpty(database: *db.Db) db.Error!bool { - // `inline for` over a comptime table list: every statement below is a - // compile-time string, so no table name is ever concatenated at run time. - inline for (config_schema.content_tables) |table| { - const count_sql = comptime if (std.mem.eql(u8, table, "clients")) - "SELECT count(*) FROM clients WHERE hand_edited = 1" - else - "SELECT count(*) FROM " ++ table; - if (try database.queryInt(count_sql) != 0) return false; - } - if (try database.queryInt("SELECT count(*) FROM groups") != 1) return false; - const seeded = try database.queryInt( - "SELECT count(*) FROM groups WHERE id = 1 AND name = 'default' AND safe_search = 0", - ); - return seeded == 1; -} - -// --------------------------------------------------------------------------- -// import -// --------------------------------------------------------------------------- - -/// Reads, parses, validates, then replaces the database contents. +/// Reads, parses, validates, then converges the database onto the file. pub fn importFile( io: std.Io, gpa: Allocator, @@ -100,16 +54,11 @@ pub fn importFile( options: Options, diags: *validate.Diagnostics, ) Error!void { - // `std.zon.parse` needs a sentinel-terminated source and `readFileAlloc` - // cannot supply one. - const source = dir.readFileAllocOptions( - io, - path, - gpa, - .limited(max_config_bytes), - .of(u8), - 0, - ) catch |e| switch (e) { + // `loader.readSource`, not `loader.readManaged`: the managed-file + // classification (ruling 2) belongs to the path `--config` names. `import` + // takes a path an operator typed at a shell that has already told them the + // file is missing, and its open failures keep their own names and exit 1. + const source = loader.readSource(io, gpa, dir, path) catch |e| switch (e) { error.StreamTooLong => return error.ConfigTooLarge, else => |other| return other, }; @@ -119,8 +68,7 @@ pub fn importFile( } /// `importFile` minus the file. It exists because every step from the parse -/// onwards is testable without touching a filesystem, and `nxdns check` (S6) -/// needs the same parse-and-validate half. +/// onwards is testable without touching a filesystem. pub fn importSource( io: std.Io, gpa: Allocator, @@ -129,324 +77,72 @@ pub fn importSource( options: Options, diags: *validate.Diagnostics, ) Error!void { - // The parsed `Config` 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. + // Arena-owned and never handed to `std.zon.parse.free`; `loader.parse` + // carries the rule and its citation. var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); - const arena = arena_state.allocator(); - - 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, - error.ParseZon => { - try reportParseFailure(diags, &zon_diag); - return error.ParseZon; - }, - }; + const cfg = try loader.parse(arena_state.allocator(), source, diags); try validate.validate(cfg, diags); const now = std.Io.Clock.real.now(io).toSeconds(); - return applyToDb(io, gpa, database, cfg, now, options); + return apply(io, gpa, database, cfg, now, options, diags); } -/// 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 `nxdns import` already renders to stderr. 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. -/// -/// `pub` because `nxdns check` parses the same file and owes the operator the -/// same one-line-per-problem output; rendering the ZON diagnostics 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 half `bootstrap` reuses: an already-parsed, already-validated config into -/// the database, all or nothing. `now` is the caller's timestamp for the runtime -/// columns the model omits. -pub fn applyToDb( +/// An already-parsed, already-validated configuration into the database, all or +/// nothing, with the diff gate in front of the commit. `now` is the caller's +/// timestamp for the runtime columns the model omits; only rows the engine +/// inserts take it. +pub fn apply( io: std.Io, gpa: Allocator, database: *db.Db, cfg: model.Config, now: i64, options: Options, + diags: *validate.Diagnostics, ) Error!void { - var tx = try db.Tx.begin(database); - errdefer tx.rollback(); + var pass = try reconcile.begin(io, gpa, database, cfg, now, .{}); + errdefer pass.rollback(); - // Inside the transaction on purpose. Checking before `BEGIN IMMEDIATE` - // would leave a TOCTOU window against a concurrently starting process; - // `BEGIN IMMEDIATE` already holds the write lock, so the check and the - // writes are one atomic unit. - if (!options.force and !try isEmpty(database)) return error.DatabaseNotEmpty; - - try liftSavedClients(database); - - inline for (config_schema.delete_order) |table| { - try database.exec("DELETE FROM " ++ table ++ ";"); + if (!options.allow_delete and pass.summary.anyDeletes()) { + // Rolled back before the diagnostic is recorded: the report can fail on + // an allocation and the database must be back as it was either way. + pass.rollback(); + try reportDeletes(diags, pass.summary); + return error.DestructiveImport; } - var group_ids: context.IdMap = .empty; - defer group_ids.deinit(gpa); - var source_ids: context.IdMap = .empty; - defer source_ids.deinit(gpa); - - try insertGroups(database, gpa, cfg, &group_ids); - try insertSources(database, gpa, cfg, &source_ids); - - const ctx: context.InsertContext = .{ - .now = now, - .group_ids = &group_ids, - .source_ids = &source_ids, - }; - - for (cfg.clients) |client| { - var buf: [canonical_buf_len]u8 = undefined; - var canonical = client; - canonical.ip = try canonicalIp(client.ip, &buf); - try clients_repo.insertClient(database, canonical, ctx); - } - try restoreSavedClients(database, group_ids.get("default").?); - - for (cfg.client_prefixes) |entry| { - var buf: [canonical_buf_len]u8 = undefined; - var canonical = entry; - canonical.prefix = try canonicalPrefix(entry.prefix, &buf); - try clients_repo.insertClientPrefix(database, canonical, ctx); - } - for (cfg.upstreams) |item| try upstreams_repo.insertUpstream(database, item, ctx); - for (cfg.group_sources) |item| try groups_repo.insertGroupSource(database, item, ctx); - for (cfg.rules) |item| try rules_repo.insertRule(database, item, ctx); - for (cfg.local_records) |item| try local_repo.insertLocalRecord(database, item, ctx); - for (cfg.forward_zones) |item| try local_repo.insertForwardZone(database, item, ctx); - - // The buffer must outlive `toSettings`: `effective.web.password_hash` points - // into it. - var hash_buf: [hash_buf_len]u8 = undefined; - var effective = cfg; - if (cfg.web.password.len != 0) { - if (cfg.web.password_hash.len != 0) return error.PasswordAndHashBothSet; - effective.web.password_hash = try hashPassword(io, gpa, cfg.web.password, &hash_buf); - } - // Operator input, never stored. `toSettings` skips the field in both - // directions; clearing it here keeps the in-memory value honest too. - effective.web.password = ""; - - var pairs: std.ArrayList(model.SettingPair) = .empty; - defer { - model.freeSettings(gpa, pairs.items); - pairs.deinit(gpa); - } - try model.toSettings(effective, gpa, &pairs); - for (pairs.items) |pair| try settings_repo.insertSetting(database, pair, ctx); - - try tx.commit(); + return pass.commit(); } -const lift_saved_clients_sql: [:0]const u8 = - \\DROP TABLE IF EXISTS temp.saved_clients; - \\CREATE TEMP TABLE saved_clients AS - \\ SELECT ip, name, hand_edited, first_seen, last_seen FROM clients; -; - -/// Carries what the wipe must not destroy over the wipe that follows: the -/// auto-materialised client rows themselves, and the observed timestamps of -/// every client row whatever its flag. +/// The counts an operator needs before deciding whether `--allow-delete` is what +/// they meant. A Zig error carries no text, so this is the whole message. /// -/// A `hand_edited = 0` row is runtime state, not configuration: the DNS path -/// wrote it from live traffic and `clients_repo.listClients` already keeps it out -/// of an export. Replacing the *configuration* must therefore not delete it — -/// but it cannot survive in place either, because `clients.group_id` references -/// `groups(id)` with no cascade, and the wipe empties `groups`. So the rows step -/// aside into the temp database and come back once `groups` holds `default` -/// again. -/// -/// The table takes every row, not only the `hand_edited = 0` ones, because -/// `first_seen` and `last_seen` are runtime state on a configured row too — the -/// tracker keeps writing `last_seen` on the clients the operator named. Saving -/// only the materialised rows would keep the observation history of the devices -/// nobody named and destroy it for the devices somebody did. `hand_edited` rides -/// along so the restore can tell the two apart. -/// -/// `IF EXISTS` because a rolled-back import must not poison the next one. -fn liftSavedClients(database: *db.Db) Error!void { - return database.exec(lift_saved_clients_sql); -} - -const merge_observed_timestamps_sql: [:0]const u8 = - \\UPDATE clients AS c - \\ SET first_seen = m.first_seen, last_seen = m.last_seen - \\ FROM temp.saved_clients m - \\ WHERE m.ip = c.ip -; - -const restore_materialised_clients_sql = - \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen) - \\SELECT m.ip, m.name, ?1, 0, m.first_seen, m.last_seen - \\ FROM temp.saved_clients m - \\ WHERE m.hand_edited = 0 - \\ AND NOT EXISTS (SELECT 1 FROM clients c WHERE c.ip = m.ip) -; - -/// Puts back what `liftSavedClients` set aside, in two steps that must stay in -/// this order: the restore drops the temp table on its way out, so the merge -/// cannot follow it. -/// -/// **The merge.** An address the config declares belongs to the config — but -/// `first_seen` and `last_seen` are not the config's to state. The model carries -/// neither field, so `insertClient` writes `now` into both as a placeholder for a -/// device it knows nothing about. When the database already held that address, the -/// placeholder is the worse of the two values and both columns come from the saved -/// row instead. Everything else on the row stays the config's: the name, the -/// group, and `hand_edited = 1`. -/// -/// Not "the earlier `first_seen` and the later `last_seen`": the placeholder is -/// the wall clock at import time and every real observation predates it, so -/// "later" would resolve to the placeholder every time and stamp each named -/// device as seen at the moment of the import. An operator restoring a backup -/// would read that as liveness. `first_seen` and `last_seen` mean "when a query -/// from this address arrived", `pruneStale` and the API both read them that way, -/// and an import is not a query. Taking both from the saved row also keeps -/// `first_seen <= last_seen`, which `upsertSeen` guarantees pairwise. -/// -/// Only the config's own clients are in the table at this point, so the update -/// needs no filter of its own, and the saved row's flag does not enter into it: a -/// device keeps its history whether the previous row was materialised or -/// configured. A `--force` re-import of the same file is the case that matters — -/// it deletes and rewrites every configured client, and without the merge each -/// one would come back claiming it was first seen at the moment of the import. -/// -/// **The restore.** Only `hand_edited = 0` rows come back. A configured client is -/// configuration, so a file that leaves its address out has removed that client -/// and the row must stay gone; its history was saved for the merge, not for a -/// resurrection. `WHERE NOT EXISTS` rather than `INSERT OR IGNORE`: the only -/// materialised row worth skipping is one whose address the config claims — the -/// operator naming a device the server had already discovered, handled by the -/// merge above — and every other constraint failure stays loud. -/// -/// The rows return to `default`, the group `upsertSeen` materialises into. -/// `first_seen` and `last_seen` cross unchanged; the row id does not, because the -/// config's clients went in first and hold the low ids now. Nothing references -/// `clients.id` inside `config.db`, and §3.6 keeps the query log out of it. -fn restoreSavedClients(database: *db.Db, default_group_id: i64) Error!void { - try database.exec(merge_observed_timestamps_sql); - { - var stmt = try database.prepare(restore_materialised_clients_sql); - defer stmt.deinit(); - try stmt.bindInt(1, default_group_id); - try stmt.exec(); - } - return database.exec("DROP TABLE temp.saved_clients;"); -} - -/// `default` goes in first and takes rowid 1. §11.2 seeds group 1 as `default` -/// and §7.2's fallback assignment depends on it; letting an import renumber it -/// would silently move every unassigned client. -/// -/// The repositories expose no insert-with-id, so the id is taken rather than -/// given: SQLite assigns rowid 1 to the first row of an empty table, and the -/// table was emptied a few statements ago. The result is checked, not assumed. -fn insertGroups(database: *db.Db, gpa: Allocator, cfg: model.Config, ids: *context.IdMap) Error!void { - const default_index = indexOfGroup(cfg.groups, "default") orelse { - log.warn("the config declares no group named 'default'", .{}); - return error.MissingDefaultGroup; - }; - - try insertGroup(database, gpa, cfg.groups[default_index], ids); - const default_id = ids.get("default").?; - if (default_id != 1) { - log.warn("group 'default' took id {d}, not 1", .{default_id}); - return error.Unexpected; +/// Truncation is silent and bounded by construction: ten table names and ten +/// counts cannot fill the buffer, and a truncated line would still name the +/// tables that mattered most. +fn reportDeletes(diags: *validate.Diagnostics, summary: reconcile.Summary) error{OutOfMemory}!void { + var buf: [512]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + var first = true; + inline for (@typeInfo(reconcile.Summary).@"struct".fields) |field| { + if (field.type == reconcile.TableCounts) { + const counts = @field(summary, field.name); + if (counts.deleted != 0) { + w.print("{s}{s} {d}", .{ if (first) "" else ", ", field.name, counts.deleted }) catch {}; + first = false; + } + } } - for (cfg.groups, 0..) |group, i| { - if (i == default_index) continue; - try insertGroup(database, gpa, group, ids); - } -} - -fn insertGroup(database: *db.Db, gpa: Allocator, group: model.Group, ids: *context.IdMap) Error!void { - try groups_repo.insertGroup(database, group, .{}); - // The key borrows from `cfg`, which outlives the transaction. - try ids.put(gpa, group.name, database.lastInsertRowid()); -} - -fn insertSources(database: *db.Db, gpa: Allocator, cfg: model.Config, ids: *context.IdMap) Error!void { - for (cfg.blocklist_sources) |item| { - try sources_repo.insertBlocklistSource(database, item, .{}); - try ids.put(gpa, item.url, database.lastInsertRowid()); - } -} - -fn indexOfGroup(groups: []const model.Group, name: []const u8) ?usize { - for (groups, 0..) |group, i| { - if (std.mem.eql(u8, group.name, name)) return i; - } - return null; -} - -/// The validator compares client addresses after canonicalisation, so the row -/// this writes must be canonical too — otherwise `fd00::1` and -/// `FD00:0:0:0:0:0:0:1` pass validation as a duplicate pair and then collide on -/// the column's `UNIQUE`. -fn canonicalIp(text: []const u8, buf: []u8) error{BadClientIp}![]const u8 { - const addr = address.NetAddress.parse(text) catch return error.BadClientIp; - var w: std.Io.Writer = .fixed(buf); - addr.format(&w) catch return error.BadClientIp; - return w.buffered(); -} - -fn canonicalPrefix(text: []const u8, buf: []u8) error{BadClientPrefix}![]const u8 { - const prefix = address.Prefix.parse(text) catch return error.BadClientPrefix; - var w: std.Io.Writer = .fixed(buf); - prefix.format(&w) catch return error.BadClientPrefix; - return w.buffered(); -} - -/// argon2id with the OWASP parameters (t=2, m=19 MiB, p=1) rather than the -/// 64 MiB `interactive_2id`, because PLAN §18 budgets under 100 MiB total on a -/// Pi 5. -/// -/// `strHash`'s error set reaches beyond this module's (it carries -/// `std.Thread.SpawnError` and the PHC encoding errors), so anything that is -/// neither out of memory nor a cancellation is reported as `error.Unexpected` -/// with the real cause logged. -fn hashPassword(io: std.Io, gpa: Allocator, password: []const u8, buf: []u8) Error![]const u8 { - return std.crypto.pwhash.argon2.strHash(password, .{ - .allocator = gpa, - .params = .owasp_2id, - .mode = .argon2id, - .encoding = .phc, - }, buf, io) catch |e| switch (e) { - error.OutOfMemory => error.OutOfMemory, - error.Canceled => error.Canceled, - else => { - log.warn("hashing web.password failed: {s}", .{@errorName(e)}); - return error.Unexpected; - }, - }; + return diags.add( + error.DestructiveImport, + "import", + .{}, + "this file would delete rows the database holds ({s}); re-run with --allow-delete to apply it", + .{w.buffered()}, + ); } // --------------------------------------------------------------------------- @@ -454,6 +150,7 @@ fn hashPassword(io: std.Io, gpa: Allocator, password: []const u8, buf: []u8) Err // --------------------------------------------------------------------------- const testing = std.testing; +const config_schema = @import("../storage/config_schema.zig"); const migrations = @import("../storage/migrations.zig"); fn openMigrated() !db.Db { @@ -465,20 +162,14 @@ fn openMigrated() !db.Db { } /// Every row of every config table, rendered in a stable order. Two dumps are -/// equal exactly when the database content is. +/// equal exactly when the database content is. Driven by `table_names` — the +/// list that includes `groups`, so a renumbered group is visible here. fn dump(database: *db.Db, gpa: Allocator) ![]u8 { var out: std.Io.Writer.Allocating = .init(gpa); errdefer out.deinit(); const w = &out.writer; - try w.writeAll("groups\n"); - var stmt = try database.prepare("SELECT id, name, safe_search FROM groups ORDER BY id"); - defer stmt.deinit(); - while (try stmt.step()) { - try w.print(" {d} {s} {d}\n", .{ stmt.columnInt(0), stmt.columnText(1), stmt.columnInt(2) }); - } - - inline for (config_schema.content_tables) |table| { + inline for (config_schema.table_names) |table| { try w.print("{s}\n", .{table}); var rows = try database.prepare("SELECT * FROM " ++ table ++ " ORDER BY 1, 2"); defer rows.deinit(); @@ -523,72 +214,56 @@ const full_source: [:0]const u8 = \\} ; +/// `full_source` with one upstream added and nothing removed: the additive +/// re-import the gate must let through untouched. +const additive_source: [:0]const u8 = + \\.{ + \\ .dns = .{ .port = 5353 }, + \\ .logging = .{ .level = .err, .retention_days = 7 }, + \\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } }, + \\ .upstreams = .{ + \\ .{ .url = "https://dns.example/dns-query", .priority = 10 }, + \\ .{ .url = "tls://192.0.2.53:853", .priority = 20, .enabled = false, .tls_name = "dot.example" }, + \\ .{ .url = "https://extra.example/dns-query", .priority = 30 }, + \\ }, + \\ .clients = .{ .{ .ip = "FD00:0:0:0:0:0:0:1", .name = "tablet", .group = "kids" } }, + \\ .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } }, + \\ .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "ads" } }, + \\ .group_sources = .{ .{ .group = "kids", .source_url = "https://lists.example/ads.txt" } }, + \\ .rules = .{ .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block } }, + \\ .local_records = .{ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 600 } }, + \\ .forward_zones = .{ .{ .zone = "lan", .resolver = "udp://192.168.1.1:53" } }, + \\} +; + +/// `full_source` with one declarative column edited on a matched identity, and +/// nothing added or removed: the edit-only re-import, which also needs no flag. +const edited_source: [:0]const u8 = + \\.{ + \\ .dns = .{ .port = 5354 }, + \\ .logging = .{ .level = .err, .retention_days = 7 }, + \\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = false } }, + \\ .upstreams = .{ + \\ .{ .url = "https://dns.example/dns-query", .priority = 10 }, + \\ .{ .url = "tls://192.0.2.53:853", .priority = 20, .enabled = false, .tls_name = "dot.example" }, + \\ }, + \\ .clients = .{ .{ .ip = "FD00:0:0:0:0:0:0:1", .name = "slate", .group = "kids" } }, + \\ .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } }, + \\ .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "advertising" } }, + \\ .group_sources = .{ .{ .group = "kids", .source_url = "https://lists.example/ads.txt" } }, + \\ .rules = .{ .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block } }, + \\ .local_records = .{ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 600 } }, + \\ .forward_zones = .{ .{ .zone = "lan", .resolver = "udp://192.168.1.1:53" } }, + \\} +; + fn importText(io: std.Io, database: *db.Db, source: [:0]const u8, options: Options) !void { var diags: validate.Diagnostics = .init(testing.allocator); defer diags.deinit(); return importSource(io, testing.allocator, database, source, options, &diags); } -test "isEmpty is true on a freshly migrated database" { - var database = try openMigrated(); - defer database.close(); - try testing.expect(try isEmpty(&database)); -} - -test "isEmpty is false once a settings row exists" { - var database = try openMigrated(); - defer database.close(); - try database.exec("INSERT INTO settings (key, value) VALUES ('dns.port', '53');"); - try testing.expect(!try isEmpty(&database)); -} - -test "isEmpty ignores the client rows live traffic materialises" { - var database = try openMigrated(); - defer database.close(); - - // The real §7.2 write path, not hand-written SQL: what makes these rows - // ignorable is that `upsertSeen` is the thing that wrote them. - try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000); - try clients_repo.upsertSeen(&database, "192.168.1.6", 1700000100); - - try testing.expectEqual(@as(i64, 2), try clients_repo.countClients(&database)); - try testing.expect(try isEmpty(&database)); -} - -test "isEmpty is false once a client carries operator intent" { - var database = try openMigrated(); - defer database.close(); - - _ = try clients_repo.insertClientRow( - &database, - .{ .ip = "192.168.1.7", .name = "printer", .group_id = 1 }, - 1700000000, - ); - try testing.expect(!try isEmpty(&database)); -} - -test "isEmpty is false once an operator edits a materialised client" { - var database = try openMigrated(); - defer database.close(); - - try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000); - try testing.expect(try isEmpty(&database)); - - // A PUT through the API is what turns a discovered device into policy, and - // that policy is exactly what a seed would replace. - 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 }); - try testing.expect(!try isEmpty(&database)); -} - -test "isEmpty is false once the seeded group is changed" { - var database = try openMigrated(); - defer database.close(); - try database.exec("UPDATE groups SET name = 'renamed' WHERE id = 1;"); - try testing.expect(!try isEmpty(&database)); -} - -test "importSource seeds a migrated database and group 'default' keeps id 1" { +test "importSource converges a migrated database and group 'default' keeps id 1" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); @@ -605,83 +280,11 @@ test "importSource seeds a migrated database and group 'default' keeps id 1" { @as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams WHERE tls_name = 'dot.example'"), ); - try testing.expectEqual( - @as(i64, 1), - try database.queryInt("SELECT count(*) FROM upstreams WHERE tls_name = ''"), - ); } -test "an import keeps the materialised clients it found and lets the config claim an address" { - var threaded: std.Io.Threaded = .init(testing.allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); - - var database = try openMigrated(); - defer database.close(); - - // Two devices the server discovered. `full_source` names the second one. - // The first is seen twice, so `first_seen` and `last_seen` differ and the - // assertions below cannot pass by carrying one column into both. - try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000); - try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000100); - try clients_repo.upsertSeen(&database, "fd00::1", 1700000200); - - try importText(io, &database, full_source, .{}); - - try testing.expectEqual(@as(i64, 2), try clients_repo.countClients(&database)); - - // The device the config says nothing about keeps its flag, its group and - // both timestamps: a seed is not a reason to forget when a device appeared. - var stmt = try database.prepare( - "SELECT hand_edited, group_id, first_seen, last_seen FROM clients WHERE ip = '192.168.1.5'", - ); - defer stmt.deinit(); - try testing.expect(try stmt.step()); - try testing.expectEqual(@as(i64, 0), stmt.columnInt(0)); - try testing.expectEqual(@as(i64, 1), stmt.columnInt(1)); - try testing.expectEqual(@as(i64, 1700000000), stmt.columnInt(2)); - try testing.expectEqual(@as(i64, 1700000100), stmt.columnInt(3)); - - // The one the config names belongs to the config: named, in `kids`, and - // hand-edited, so a later prune leaves it alone. - try testing.expectEqual(@as(i64, 1), try database.queryInt( - \\SELECT count(*) FROM clients c JOIN groups g ON g.id = c.group_id - \\ WHERE c.ip = 'fd00::1' AND c.hand_edited = 1 AND c.name = 'tablet' AND g.name = 'kids' - )); -} - -test "the config claiming a discovered address keeps that device's observed timestamps" { - var threaded: std.Io.Threaded = .init(testing.allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); - - var database = try openMigrated(); - defer database.close(); - - // The device is seen twice, so the two timestamps differ and neither - // assertion below can pass by carrying one column into the other. - // `full_source` names this address. - try clients_repo.upsertSeen(&database, "fd00::1", 1700000200); - try clients_repo.upsertSeen(&database, "fd00::1", 1700000900); - - try importText(io, &database, full_source, .{}); - - var stmt = try database.prepare( - "SELECT hand_edited, name, first_seen, last_seen FROM clients WHERE ip = 'fd00::1'", - ); - defer stmt.deinit(); - try testing.expect(try stmt.step()); - // The row is the config's: named, hand-edited. - try testing.expectEqual(@as(i64, 1), stmt.columnInt(0)); - try testing.expectEqualStrings("tablet", stmt.columnText(1)); - // The observation history is the tracker's. The import clock is `now`, so - // both columns would hold a value far above these if the import had written - // its own. - try testing.expectEqual(@as(i64, 1700000200), stmt.columnInt(2)); - try testing.expectEqual(@as(i64, 1700000900), stmt.columnInt(3)); -} - -test "a failed import leaves the observed timestamps exactly as they were" { +test "an import whose diff deletes rows is refused, names the tables, and changes nothing" { + // Ruling 6: the emptiness guard is gone, so this is what stops + // `nxdns import ./wrong.zon` from emptying a configured database. var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); @@ -689,129 +292,91 @@ test "a failed import leaves the observed timestamps exactly as they were" { var database = try openMigrated(); defer database.close(); - - // One address the config below claims, one it says nothing about. - try clients_repo.upsertSeen(&database, "fd00::1", 1700000200); - try clients_repo.upsertSeen(&database, "fd00::1", 1700000900); - try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000); + try importText(io, &database, full_source, .{}); const before = try dump(&database, gpa); defer gpa.free(before); - // The clients go in, the timestamps merge, and then two identical local - // records violate `UNIQUE(name, rtype, value)`. Everything the import wrote - // must go with the transaction. - const broken: model.Config = .{ - .groups = &.{.{ .name = "default" }}, - .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, - .clients = &.{.{ .ip = "fd00::1", .name = "tablet" }}, - .local_records = &.{ - .{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" }, - .{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" }, - }, - }; - try testing.expectError(error.Constraint, applyToDb(io, gpa, &database, broken, 42, .{})); - - const after = try dump(&database, gpa); - defer gpa.free(after); - try testing.expectEqualStrings(before, after); -} - -test "a forced re-import replaces the configured clients and keeps the materialised ones" { - var threaded: std.Io.Threaded = .init(testing.allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); - - var database = try openMigrated(); - defer database.close(); - try importText(io, &database, full_source, .{}); - try clients_repo.upsertSeen(&database, "10.0.0.9", 1700000200); - // The device the old file named keeps querying, so its row carries real - // observation history when the wipe reaches it. - try clients_repo.upsertSeen(&database, "fd00::1", 1700003000); - - try importText(io, &database, minimal_source, .{ .force = true }); - - // `full_source`'s hand-edited client went with the rest of the old - // configuration; the discovered one did not. - try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database)); - try testing.expectEqual(@as(i64, 1700000200), try database.queryInt( - "SELECT first_seen FROM clients WHERE ip = '10.0.0.9' AND hand_edited = 0", - )); - // The lift saves a configured client's history so the merge can hand it back - // to the same address. It must never become a reason to resurrect a client - // the new file leaves out: dropping a client from the file removes it. - try testing.expectEqual(@as(i64, 0), try database.queryInt( - "SELECT count(*) FROM clients WHERE ip = 'fd00::1'", - )); - try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams")); -} - -test "a forced re-import keeps the observed timestamps of a client the config names" { - var threaded: std.Io.Threaded = .init(testing.allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); - - var database = try openMigrated(); - defer database.close(); - - // The device appears in traffic, the operator's file names it, and it keeps - // querying afterwards. The row is now `hand_edited = 1` and carries a real - // `first_seen` and a later `last_seen`. - try clients_repo.upsertSeen(&database, "fd00::1", 1700000200); - try importText(io, &database, full_source, .{}); - try clients_repo.upsertSeen(&database, "fd00::1", 1700005000); - - try importText(io, &database, full_source, .{ .force = true }); - - var stmt = try database.prepare( - "SELECT hand_edited, first_seen, last_seen FROM clients WHERE ip = 'fd00::1'", - ); - defer stmt.deinit(); - try testing.expect(try stmt.step()); - try testing.expectEqual(@as(i64, 1), stmt.columnInt(0)); - // Re-importing the same file is not an observation of the device. - try testing.expectEqual(@as(i64, 1700000200), stmt.columnInt(1)); - try testing.expectEqual(@as(i64, 1700005000), stmt.columnInt(2)); -} - -test "a failed forced import leaves every client timestamp exactly as it was" { - 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(); - - // A configured client with observation history, and a materialised one. - try importText(io, &database, full_source, .{}); - try clients_repo.upsertSeen(&database, "fd00::1", 1700005000); - try clients_repo.upsertSeen(&database, "10.0.0.9", 1700000200); - - const before = try dump(&database, gpa); - defer gpa.free(before); - - const broken: model.Config = .{ - .groups = &.{.{ .name = "default" }}, - .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, - .clients = &.{.{ .ip = "fd00::1", .name = "tablet" }}, - .local_records = &.{ - .{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" }, - .{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" }, - }, - }; + var diags: validate.Diagnostics = .init(gpa); + defer diags.deinit(); try testing.expectError( - error.Constraint, - applyToDb(io, gpa, &database, broken, 42, .{ .force = true }), + error.DestructiveImport, + importSource(io, gpa, &database, minimal_source, .{}, &diags), ); + // Byte-identical, which is the rollback proof: `total_changes` is + // connection-scoped and counts the writes a rollback undid. const after = try dump(&database, gpa); defer gpa.free(after); try testing.expectEqualStrings(before, after); + + // The per-table counts are the message: an operator has to see what would + // have gone before deciding that `--allow-delete` is what they meant. + var rendered: std.Io.Writer.Allocating = .init(gpa); + defer rendered.deinit(); + try diags.writeAll(&rendered.writer); + const text = rendered.written(); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "groups 1")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "upstreams 1")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "rules 1")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "--allow-delete")); } -test "applyToDb without force refuses a configured database and changes nothing" { +test "the same import applies once --allow-delete is given" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var database = try openMigrated(); + defer database.close(); + try importText(io, &database, full_source, .{}); + + try importText(io, &database, minimal_source, .{ .allow_delete = true }); + + try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams")); + try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM groups")); + try testing.expectEqual(@as(i64, 0), try database.queryInt("SELECT count(*) FROM rules")); +} + +test "an additive import needs no flag" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var database = try openMigrated(); + defer database.close(); + try importText(io, &database, full_source, .{}); + + try importText(io, &database, additive_source, .{}); + try testing.expectEqual(@as(i64, 3), try database.queryInt("SELECT count(*) FROM upstreams")); +} + +test "an edit-only import needs no flag" { + // "Edit-only" is edits to declarative columns on a matched identity. An edit + // that changes an identity column is a delete plus an insert to the engine, + // and does need the flag — which is why this file edits names, priorities + // and flags rather than urls. + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var database = try openMigrated(); + defer database.close(); + try importText(io, &database, full_source, .{}); + + try importText(io, &database, edited_source, .{}); + try testing.expectEqual(@as(i64, 1), try database.queryInt( + "SELECT count(*) FROM clients WHERE ip = 'fd00::1' AND name = 'slate'", + )); + try testing.expectEqual(@as(i64, 1), try database.queryInt( + "SELECT count(*) FROM blocklist_sources WHERE name = 'advertising'", + )); + try testing.expectEqual(@as(i64, 0), try database.queryInt( + "SELECT count(*) FROM groups WHERE name = 'kids' AND safe_search = 1", + )); +} + +test "an identity-column edit is a delete plus an insert, and needs the flag" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); @@ -819,23 +384,26 @@ test "applyToDb without force refuses a configured database and changes nothing" var database = try openMigrated(); defer database.close(); - try importText(io, &database, full_source, .{}); + try importText(io, &database, minimal_source, .{}); - const before = try dump(&database, gpa); - defer gpa.free(before); - - const second: model.Config = .{ - .groups = &.{.{ .name = "default" }}, - .upstreams = &.{.{ .url = "https://other.example/dns-query" }}, - }; - try testing.expectError(error.DatabaseNotEmpty, applyToDb(io, gpa, &database, second, 42, .{})); - - const after = try dump(&database, gpa); - defer gpa.free(after); - try testing.expectEqualStrings(before, after); + const moved_url: [:0]const u8 = + \\.{ + \\ .groups = .{ .{ .name = "default" } }, + \\ .upstreams = .{ .{ .url = "https://other.example/dns-query" } }, + \\} + ; + var diags: validate.Diagnostics = .init(gpa); + defer diags.deinit(); + try testing.expectError( + error.DestructiveImport, + importSource(io, gpa, &database, moved_url, .{}, &diags), + ); + try testing.expectEqual(@as(i64, 1), try database.queryInt( + "SELECT count(*) FROM upstreams WHERE url = 'https://dns.example/dns-query'", + )); } -test "applyToDb rolls back completely when an insert fails mid-way" { +test "an import that fails mid-transaction leaves the database as it was" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); @@ -849,17 +417,22 @@ test "applyToDb rolls back completely when an insert fails mid-way" { defer gpa.free(before); // Two identical local records violate `UNIQUE(name, rtype, value)`. The - // validator would catch this, which is exactly why the test calls - // `applyToDb` directly: the all-or-nothing guarantee has to hold on its own. + // validator would catch this, which is exactly why the test calls `apply` + // directly: the all-or-nothing guarantee has to hold on its own. const broken: model.Config = .{ .groups = &.{.{ .name = "default" }}, - .upstreams = &.{.{ .url = "https://other.example/dns-query" }}, + .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, .local_records = &.{ .{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" }, .{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" }, }, }; - try testing.expectError(error.Constraint, applyToDb(io, gpa, &database, broken, 42, .{ .force = true })); + var diags: validate.Diagnostics = .init(gpa); + defer diags.deinit(); + try testing.expectError( + error.Constraint, + apply(io, gpa, &database, broken, 42, .{ .allow_delete = true }, &diags), + ); const after = try dump(&database, gpa); defer gpa.free(after); @@ -870,48 +443,61 @@ test "importSource writes nothing when validation fails" { 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(); + const before = try dump(&database, gpa); + defer gpa.free(before); + // No `default` group and no enabled upstream. const bad: [:0]const u8 = \\.{ .groups = .{ .{ .name = "kids" } } } ; - var diags: validate.Diagnostics = .init(testing.allocator); + var diags: validate.Diagnostics = .init(gpa); defer diags.deinit(); try testing.expectError( error.MissingDefaultGroup, - importSource(io, testing.allocator, &database, bad, .{}, &diags), + importSource(io, gpa, &database, bad, .{}, &diags), ); try testing.expect(diags.problems.items.len >= 2); - try testing.expect(try isEmpty(&database)); + + const after = try dump(&database, gpa); + defer gpa.free(after); + try testing.expectEqualStrings(before, after); } test "importSource reports a ZON syntax error and writes nothing" { 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 diags: validate.Diagnostics = .init(testing.allocator); + const before = try dump(&database, gpa); + defer gpa.free(before); + + var diags: validate.Diagnostics = .init(gpa); defer diags.deinit(); try testing.expectError( error.ParseZon, - importSource(io, testing.allocator, &database, ".{ .groups = ", .{}, &diags), + importSource(io, gpa, &database, ".{ .groups = ", .{}, &diags), ); - try testing.expect(try isEmpty(&database)); + + const after = try dump(&database, gpa); + defer gpa.free(after); + try testing.expectEqualStrings(before, after); // The point of the diagnostic: what the CLI prints must name the line and the // column, not just `ParseZon`. try testing.expect(diags.problems.items.len >= 1); - var rendered: std.Io.Writer.Allocating = .init(testing.allocator); + var rendered: std.Io.Writer.Allocating = .init(gpa); defer rendered.deinit(); try diags.writeAll(&rendered.writer); - const text = rendered.written(); - try testing.expect(std.mem.indexOf(u8, text, "1:14: error: ") != null); + try testing.expect(std.mem.indexOf(u8, rendered.written(), "1:14: error: ") != null); } test "importSource splits a multi-line ZON failure into one problem per message" { @@ -946,13 +532,9 @@ test "a config omitting every optional field parses into an arena and leaks noth var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); - const cfg = try std.zon.parse.fromSliceAlloc( - model.Config, - arena_state.allocator(), - minimal_source, - null, - .{}, - ); + var diags: validate.Diagnostics = .init(testing.allocator); + defer diags.deinit(); + const cfg = try loader.parse(arena_state.allocator(), minimal_source, &diags); try testing.expectEqualStrings("0.0.0.0", cfg.dns.bind_ipv4); try testing.expectEqual(@as(u16, 53), cfg.dns.port); try testing.expectEqual(@as(usize, 1), cfg.groups.len); @@ -972,7 +554,9 @@ test "a password is hashed into web.password_hash and never stored verbatim" { .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, .web = .{ .password = "correct horse battery staple" }, }; - try applyToDb(io, gpa, &database, cfg, 42, .{}); + var diags: validate.Diagnostics = .init(gpa); + defer diags.deinit(); + try apply(io, gpa, &database, cfg, 42, .{}, &diags); var stmt = try database.prepare("SELECT value FROM settings WHERE key = 'web.password_hash'"); defer stmt.deinit(); @@ -989,18 +573,27 @@ test "a password and a password_hash together are refused" { 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(); + const before = try dump(&database, gpa); + defer gpa.free(before); + const cfg: model.Config = .{ .groups = &.{.{ .name = "default" }}, .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, .web = .{ .password = "plaintext", .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" }, }; + var diags: validate.Diagnostics = .init(gpa); + defer diags.deinit(); try testing.expectError( error.PasswordAndHashBothSet, - applyToDb(io, testing.allocator, &database, cfg, 42, .{}), + apply(io, gpa, &database, cfg, 42, .{}, &diags), ); - try testing.expect(try isEmpty(&database)); + + const after = try dump(&database, gpa); + defer gpa.free(after); + try testing.expectEqualStrings(before, after); } diff --git a/src/config/loader.zig b/src/config/loader.zig new file mode 100644 index 0000000..daf3081 --- /dev/null +++ b/src/config/loader.zig @@ -0,0 +1,323 @@ +//! The one way a configuration file becomes a `model.Config`. +//! +//! `nxdns run --config ` and `nxdns check --config ` 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); +} diff --git a/src/config/model.zig b/src/config/model.zig index 8c2a3e9..8343d86 100644 --- a/src/config/model.zig +++ b/src/config/model.zig @@ -87,10 +87,16 @@ pub const Web = struct { enabled: bool = true, bind: []const u8 = "0.0.0.0", port: u16 = 8080, - /// Operator input only. Never a settings row, always exported as "". - password: []const u8 = "", - /// argon2id PHC string; "" disables authentication. - password_hash: []const u8 = "", + /// Operator input only. Never a settings row, never exported. + /// + /// Optional because absence and emptiness are different declarations: null + /// means "the file says nothing about the password, keep the stored hash", + /// while a present value is an instruction to set one. + password: ?[]const u8 = null, + /// argon2id PHC string. Null means "the file says nothing, keep what is + /// stored"; an explicit `""` is the documented way to disable + /// authentication. + password_hash: ?[]const u8 = null, session_ttl_hours: u16 = 24, api_rate_limit_per_min: u32 = 300, /// Requests from the box itself skip the API rate limit. On by default: a @@ -387,9 +393,23 @@ fn isScalarSection(comptime T: type) bool { return @typeInfo(T) == .@"struct"; } -/// `web.password` is operator input, never a settings row: it is hashed into -/// `web.password_hash` at import time and discarded (S2.5). -fn isSkipped(comptime section: []const u8, comptime field: []const u8) bool { +/// The skip policy splits by direction, because encode and decode need +/// different sets. +/// +/// `web.password` is operator input and is skipped both ways: it is hashed into +/// `web.password_hash` and discarded (S2.5). +/// +/// `web.password_hash` is skipped on **encode only**. The reconcile engine owns +/// that settings row directly — ruling 4 of milestone 20 makes absence mean +/// "keep the stored hash", which a general encode pass cannot express. Skipping +/// it on decode as well would leave `cfg.web.password_hash` null on every read +/// path, turn `auth.authEnabled` false, and silently open the admin UI. +fn isEncodeSkipped(comptime section: []const u8, comptime field: []const u8) bool { + if (!std.mem.eql(u8, section, "web")) return false; + return std.mem.eql(u8, field, "password") or std.mem.eql(u8, field, "password_hash"); +} + +fn isDecodeSkipped(comptime section: []const u8, comptime field: []const u8) bool { return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password"); } @@ -416,6 +436,10 @@ fn decodeValue(comptime T: type, text: []const u8) error{BadSettingValue}!T { .int => std.fmt.parseInt(T, text, 10) catch error.BadSettingValue, .@"enum" => T.fromDb(text) orelse error.BadSettingValue, .pointer => text, + // A stored key is a present value, so an optional field decodes to a + // non-null one; the null stays reserved for the absent key, which never + // reaches this function at all. + .optional => |info| try decodeValue(info.child, text), else => @compileError("unsupported setting field type " ++ @typeName(T)), }; } @@ -441,7 +465,7 @@ pub fn toSettings(cfg: Config, gpa: Allocator, out: *std.ArrayList(SettingPair)) if (comptime isScalarSection(section_field.type)) { const section = @field(cfg, section_field.name); inline for (@typeInfo(section_field.type).@"struct".fields) |field| { - if (comptime !isSkipped(section_field.name, field.name)) { + if (comptime !isEncodeSkipped(section_field.name, field.name)) { const value = try encodeValue(field.type, @field(section, field.name), gpa); errdefer gpa.free(value); try out.append(gpa, .{ .key = section_field.name ++ "." ++ field.name, .value = value }); @@ -465,7 +489,7 @@ pub fn fromSettings(pairs: []const SettingPair, cfg: *Config, unknown_keys: *usi inline for (@typeInfo(Config).@"struct".fields) |section_field| { if (comptime isScalarSection(section_field.type)) { inline for (@typeInfo(section_field.type).@"struct".fields) |field| { - if (comptime !isSkipped(section_field.name, field.name)) { + if (comptime !isDecodeSkipped(section_field.name, field.name)) { if (std.mem.eql(u8, pair.key, section_field.name ++ "." ++ field.name)) { @field(@field(cfg, section_field.name), field.name) = try decodeValue(field.type, pair.value); @@ -531,7 +555,6 @@ const expected_keys = [_][]const u8{ "web.api_rate_limit_per_min", "web.bind", "web.enabled", - "web.password_hash", "web.port", "web.session_ttl_hours", "web.sse_max_connections_per_ip", @@ -642,7 +665,7 @@ test "toSettings and fromSettings round-trip a non-default config" { inline for (@typeInfo(Config).@"struct".fields) |section_field| { if (comptime isScalarSection(section_field.type)) { inline for (@typeInfo(section_field.type).@"struct".fields) |field| { - if (comptime !isSkipped(section_field.name, field.name)) { + if (comptime !isEncodeSkipped(section_field.name, field.name)) { const a = @field(@field(original, section_field.name), field.name); const b = @field(@field(restored, section_field.name), field.name); if (comptime @typeInfo(field.type) == .pointer) { @@ -671,6 +694,48 @@ test "an unknown settings key is counted and not an error" { try testing.expectEqual(@as(usize, 2), unknown); } +test "web.password_hash decodes from the settings table but is never encoded" { + const gpa = testing.allocator; + var pairs: std.ArrayList(SettingPair) = .empty; + defer { + freeSettings(gpa, pairs.items); + pairs.deinit(gpa); + } + + // Encode: the reconciler owns that row, so no pass over the model emits it. + const hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def"; + try toSettings(.{ .web = .{ .password_hash = hash } }, gpa, &pairs); + for (pairs.items) |pair| { + try testing.expect(!std.mem.eql(u8, pair.key, "web.password_hash")); + try testing.expect(!std.mem.eql(u8, pair.key, "web.password")); + } + + // Decode: every read path still sees the stored hash, or `authEnabled` + // would read false on a box that has a password set. + var cfg: Config = .{}; + var unknown: usize = 0; + const stored = [_]SettingPair{.{ .key = "web.password_hash", .value = hash }}; + try fromSettings(&stored, &cfg, &unknown); + try testing.expectEqual(@as(usize, 0), unknown); + try testing.expectEqualStrings(hash, cfg.web.password_hash.?); +} + +test "an optional settings field is null when absent and non-null when present" { + var absent: Config = .{}; + var unknown: usize = 0; + const other = [_]SettingPair{.{ .key = "dns.port", .value = "5300" }}; + try fromSettings(&other, &absent, &unknown); + try testing.expectEqual(@as(?[]const u8, null), absent.web.password_hash); + + // An explicit empty string is a present value, not an absent key: it is how + // a config file disables authentication. + var empty: Config = .{}; + const disabled = [_]SettingPair{.{ .key = "web.password_hash", .value = "" }}; + try fromSettings(&disabled, &empty, &unknown); + try testing.expect(empty.web.password_hash != null); + try testing.expectEqualStrings("", empty.web.password_hash.?); +} + test "a malformed settings value is BadSettingValue" { var cfg: Config = .{}; var unknown: usize = 0; diff --git a/src/config/reconcile.zig b/src/config/reconcile.zig new file mode 100644 index 0000000..4263f62 --- /dev/null +++ b/src/config/reconcile.zig @@ -0,0 +1,1962 @@ +//! Converges `config.db` onto a parsed, validated configuration without wiping +//! it (milestone 20, rulings 3, 4 and 5). +//! +//! The defect this module exists to fix: `import.applyToDb` deletes and +//! reinserts every row, `blocklist_sources` included, and the compiled +//! blocklists are named after the source row id (`.list` / `.wild`). A +//! configuration re-applied on every boot would therefore hand every source a +//! new id, orphan every compiled file, and re-download every blocklist on every +//! restart. +//! +//! So nothing is wiped. Every table has an identity; a row the file and the +//! database agree on is **updated in place**, keeping its row id and every +//! runtime column beside it — checksum, `last_updated`, counters, +//! `first_seen` / `last_seen`, `created_at`. A row the file no longer declares +//! is deleted. A row the file declares and the database lacks is inserted. +//! +//! Two properties are load-bearing, and the tests at the bottom exist for them: +//! +//! * **Idempotence.** Applying the same configuration twice leaves the +//! database byte-identical, non-canonical addresses and plaintext passwords +//! included. +//! * **Writes only on difference.** A matched row whose declarative columns +//! already hold the file's values is not written at all, so an unchanged +//! boot moves `sqlite3_total_changes` by zero and needs no WAL headroom. A +//! full SD card cannot brick a restart that a wipe-free no-op would have +//! survived. +//! +//! Pure of `std.Io` except where argon2 needs it. No filesystem access: the +//! caller has already read and validated the file. +//! +//! **The caller decides the commit.** `begin` opens one `BEGIN IMMEDIATE`, runs +//! every pass, and returns a `Pass` with that transaction still open and the +//! `Summary` already filled in. Finishing it is the caller's move: +//! `Pass.commit` or `Pass.rollback`, and one of the two must run. +//! +//! This shape is not a convenience — it is what ruling 6 needs. `nxdns import` +//! refuses a run whose diff would delete rows unless `--allow-delete` says +//! otherwise, and it has to decide that *after* seeing the counts and *inside* +//! the write lock that produced them. An engine that committed on its own would +//! leave the gate reading a database it had already changed, which is the +//! TOCTOU window `BEGIN IMMEDIATE` exists to close: +//! +//! ```zig +//! var pass = try reconcile.begin(io, gpa, database, cfg, now, .{}); +//! errdefer pass.rollback(); +//! if (!allow_delete and pass.summary.anyDeletes()) { +//! pass.rollback(); +//! return error.DestructiveImport; +//! } +//! try pass.commit(); +//! ``` +//! +//! A failure *inside* `begin` needs no cleanup: it rolls its own transaction +//! back and returns the error, so there is no half-open `Pass` to handle. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const db = @import("../storage/db.zig"); +const config_schema = @import("../storage/config_schema.zig"); +const context = @import("../storage/repositories/context.zig"); +const clients_repo = @import("../storage/repositories/clients_repo.zig"); +const groups_repo = @import("../storage/repositories/groups_repo.zig"); +const local_repo = @import("../storage/repositories/local_repo.zig"); +const rules_repo = @import("../storage/repositories/rules_repo.zig"); +const settings_repo = @import("../storage/repositories/settings_repo.zig"); +const sources_repo = @import("../storage/repositories/sources_repo.zig"); +const upstreams_repo = @import("../storage/repositories/upstreams_repo.zig"); +const address = @import("../platform/address.zig"); +const model = @import("model.zig"); + +const log = std.log.scoped(.config_reconcile); + +/// The row id migration step 1 seeds `groups` with. `§7.2`'s fallback +/// assignment and `upsertSeen` both depend on it, so the engine refuses to +/// commit a pass that would move it. +pub const default_group_id: i64 = 1; + +/// The name of that row. The engine matches it and never creates it. +pub const default_group_name = "default"; + +/// The settings key the engine writes directly rather than through +/// `model.toSettings`, and the one key its sweep never removes (ruling 4). +pub const password_hash_key = "web.password_hash"; + +/// Holds any PHC-encoded argon2id string comfortably (import.zig's number). +const hash_buf_len = 256; + +/// The canonical text of an IPv6 prefix, the longest value canonicalised here. +const canonical_buf_len = 64; + +pub const Error = db.Error || error{ + BadClientIp, + BadClientPrefix, + MissingDefaultGroup, + PasswordAndHashBothSet, + Canceled, +}; + +pub const TableCounts = struct { + inserted: u32 = 0, + updated: u32 = 0, + deleted: u32 = 0, + + pub fn total(self: TableCounts) u32 { + return self.inserted + self.updated + self.deleted; + } +}; + +/// Which way authentication moved, so a change to it is never a silent line +/// item in a count (ruling 4). +pub const AuthTransition = enum { none, enabled, disabled, rotated }; + +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: AuthTransition = .none, + + /// True when the pass changed nothing at all — the answer an unchanged + /// configuration must produce. + pub fn isNoOp(self: Summary) bool { + return self.totals().total() == 0 and self.auth_transition == .none; + } + + /// True when any table lost a row. `import`'s diff gate (ruling 6) is this + /// predicate: reconcile never deletes runtime state, but deleting a + /// declarative row the file stopped declaring is still destructive. + pub fn anyDeletes(self: Summary) bool { + return self.totals().deleted != 0; + } + + /// The per-table counts added together. + pub fn totals(self: Summary) TableCounts { + var sum: TableCounts = .{}; + inline for (@typeInfo(Summary).@"struct".fields) |field| { + if (field.type == TableCounts) { + const counts = @field(self, field.name); + sum.inserted += counts.inserted; + sum.updated += counts.updated; + sum.deleted += counts.deleted; + } + } + return sum; + } +}; + +pub const Options = struct { + /// Collects the settings keys this pass wrote or removed, so the startup + /// summary can name them (ruling 8 — the keys, never the values). Each key + /// is duplicated into the `gpa` passed to `reconcile`; the caller owns and + /// frees them. Null collects nothing. + changed_settings: ?*std.ArrayList([]const u8) = null, +}; + +/// A finished set of passes whose transaction is still open, and the summary of +/// what they did. Exactly one of `commit` and `rollback` must run. +/// +/// Nothing this holds outlives the call that produced it: the `Summary` is +/// plain counts and the `Tx` addresses the caller's own `Db`, so a `Pass` is +/// free to be moved or returned. +pub const Pass = struct { + tx: db.Tx, + summary: Summary, + + /// Publishes every write the passes made. + pub fn commit(self: *Pass) Error!void { + return self.tx.commit(); + } + + /// Discards every write the passes made, leaving the database + /// byte-for-byte as it was. Safe to call twice and safe after `commit`, so + /// it works as an `errdefer` beside an explicit call. + pub fn rollback(self: *Pass) void { + self.tx.rollback(); + } +}; + +/// Converges `database` onto `cfg` inside one `BEGIN IMMEDIATE`, and returns +/// with that transaction **open** — see this file's header for why, and for the +/// `import` diff-gate shape it exists to serve. +/// +/// `now` is the caller's wall clock in epoch seconds, used only for the runtime +/// columns a newly inserted row needs (`first_seen`, `last_seen`, +/// `created_at`). No existing row's timestamp is ever restamped. +/// +/// On failure the transaction is already rolled back and the database is +/// byte-for-byte as it was; the caller has nothing to finish. +pub fn begin( + io: std.Io, + gpa: Allocator, + database: *db.Db, + cfg: model.Config, + now: i64, + options: Options, +) Error!Pass { + var tx = try db.Tx.begin(database); + errdefer tx.rollback(); + + const summary = try runPasses(io, gpa, database, cfg, now, options); + + return .{ .tx = tx, .summary = summary }; +} + +/// Every pass, in order, on a transaction the caller has already opened. +fn runPasses( + io: std.Io, + gpa: Allocator, + database: *db.Db, + cfg: model.Config, + now: i64, + options: Options, +) Error!Summary { + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var summary: Summary = .{}; + var doomed: Doomed = .{}; + + // --- Phase A: upserts, parents first ----------------------------------- + // + // The referrers below resolve group names and source urls to the ids these + // two passes have just settled, so nothing here may be reordered. + + var group_ids: context.IdMap = .empty; + var source_ids: context.IdMap = .empty; + + try reconcileGroups(database, arena, cfg, &group_ids, &summary.groups, &doomed); + // `reconcileGroups` has already refused a database whose group 1 is not the + // seeded `default`, so what is left to catch here is a file that never + // declares the group at all. + _ = group_ids.get(default_group_name) orelse return error.MissingDefaultGroup; + + try reconcileSources(database, arena, cfg, &source_ids, &summary.sources, &doomed); + + const ctx: context.InsertContext = .{ + .now = now, + .group_ids = &group_ids, + .source_ids = &source_ids, + }; + + try reconcileClients(database, arena, cfg, ctx, &summary.clients, &doomed); + try reconcileClientPrefixes(database, arena, cfg, ctx, &summary.client_prefixes, &doomed); + try reconcileRules(database, arena, cfg, ctx, &summary.rules, &doomed); + try reconcileGroupSources(database, arena, cfg, ctx, &summary.group_sources, &doomed); + try reconcileUpstreams(database, arena, cfg, ctx, &summary.upstreams, &doomed); + try reconcileLocalRecords(database, arena, cfg, ctx, &summary.local_records, &doomed); + try reconcileForwardZones(database, arena, cfg, ctx, &summary.forward_zones, &doomed); + try reconcileSettings(io, gpa, arena, database, cfg, &summary, &doomed, options); + + // --- Phase B: delete what the file no longer declares, child first ------ + // + // In `config_schema.delete_order`. Every declarative child of a dying group + // is itself absent from the file — the validator guarantees a file's rules + // and prefixes name a file's groups — so the child passes have already + // deleted and counted those rows by the time the parent goes. The FK + // cascades on `rules`, `client_prefixes` and `group_sources` are a safety + // net here, never the accountant: a cascade that fired would remove rows + // this summary did not count. + comptime std.debug.assert(config_schema.delete_order.len == 10); + + for (doomed.group_sources.items) |pair| { + try groups_repo.deleteGroupSourcePair(database, pair); + summary.group_sources.deleted += 1; + } + for (doomed.rules.items) |id| { + try rules_repo.deleteRule(database, id); + summary.rules.deleted += 1; + } + for (doomed.client_prefixes.items) |id| { + try clients_repo.deleteClientPrefix(database, id); + summary.client_prefixes.deleted += 1; + } + for (doomed.clients.items) |id| { + try clients_repo.deleteClient(database, id); + summary.clients.deleted += 1; + } + for (doomed.upstreams.items) |id| { + try upstreams_repo.deleteUpstream(database, id); + summary.upstreams.deleted += 1; + } + for (doomed.local_records.items) |id| { + try local_repo.deleteLocalRecord(database, id); + summary.local_records.deleted += 1; + } + for (doomed.forward_zones.items) |id| { + try local_repo.deleteForwardZone(database, id); + summary.forward_zones.deleted += 1; + } + for (doomed.settings.items) |key| { + try settings_repo.deleteSetting(database, key); + summary.settings.deleted += 1; + try recordSettingsKey(gpa, options, key); + } + for (doomed.sources.items) |id| { + try sources_repo.deleteSource(database, id); + summary.sources.deleted += 1; + } + + // Immediately before the groups pass, and only here: `clients.group_id` + // carries no `ON DELETE` action, so an observed device sitting in a group + // the file stopped declaring would trip the foreign key mid-transaction — + // exit 1 and a restart loop, after a `check` that said the file was fine. + for (doomed.groups.items) |id| { + const moved = try clients_repo.reassignObservedClients(database, id, default_group_id); + summary.clients.updated += moved; + } + for (doomed.groups.items) |id| { + try groups_repo.deleteGroup(database, id); + summary.groups.deleted += 1; + } + + return summary; +} + +/// What Phase A found in the database and the file no longer declares. Every +/// list is arena-owned and holds identities, not rows: Phase B does the +/// deleting, in the order the foreign keys require. +const Doomed = struct { + groups: std.ArrayList(i64) = .empty, + sources: std.ArrayList(i64) = .empty, + clients: std.ArrayList(i64) = .empty, + client_prefixes: std.ArrayList(i64) = .empty, + rules: std.ArrayList(i64) = .empty, + group_sources: std.ArrayList(groups_repo.GroupSourcePair) = .empty, + upstreams: std.ArrayList(i64) = .empty, + local_records: std.ArrayList(i64) = .empty, + forward_zones: std.ArrayList(i64) = .empty, + settings: std.ArrayList([]const u8) = .empty, +}; + +// --------------------------------------------------------------------------- +// Phase A, table by table +// --------------------------------------------------------------------------- + +fn reconcileGroups( + database: *db.Db, + arena: Allocator, + cfg: model.Config, + ids: *context.IdMap, + counts: *TableCounts, + doomed: *Doomed, +) Error!void { + const rows = try groups_repo.listGroupRows(database, arena); + const matched = try matchFlags(arena, rows.items.len); + + // The pinned row is validated before any desired group can claim it. + // Migration step 1 seeds `(1, 'default')` and the API refuses to rename or + // delete it, so an absent or renamed group 1 means a hand-edited database. + // The engine refuses that database rather than reseeding it: recreating the + // group here would hand the fallback a fresh id on any table whose rowids + // have moved on, and every unassigned client would follow it silently. + const pinned = pinnedDefault(rows.items) orelse { + log.warn( + "group {d} is absent or is not named '{s}'", + .{ default_group_id, default_group_name }, + ); + return error.Unexpected; + }; + + for (cfg.groups) |group| { + // `default` matches the pinned row and nothing else. It is never + // inserted, whatever the file declares and whatever the table holds. + const found = if (std.mem.eql(u8, group.name, default_group_name)) + claim(matched, pinned) + else + findUnmatched(rows.items, matched, group, matchGroup); + + if (found) |i| { + const row = rows.items[i]; + if (row.safe_search != group.safe_search) { + try groups_repo.updateGroup(database, row.id, group); + counts.updated += 1; + } + // The key borrows from `cfg`, which outlives the transaction. + try ids.put(arena, group.name, row.id); + } else { + const id = try groups_repo.insertGroupRow(database, group); + counts.inserted += 1; + try ids.put(arena, group.name, id); + } + } + + for (rows.items, matched) |row, hit| { + if (!hit) try doomed.groups.append(arena, row.id); + } +} + +fn matchGroup(row: groups_repo.GroupRow, group: model.Group) bool { + return std.mem.eql(u8, row.name, group.name); +} + +/// The index of the row the migration pinned, or null when group 1 is absent or +/// carries another name. +fn pinnedDefault(rows: []const groups_repo.GroupRow) ?usize { + for (rows, 0..) |row, i| { + if (row.id != default_group_id) continue; + return if (std.mem.eql(u8, row.name, default_group_name)) i else null; + } + return null; +} + +/// Takes one named row, the way `findUnmatched` takes a searched one. Null when +/// something has claimed it already, which for `default` means the file +/// declared the group twice. +fn claim(matched: []bool, i: usize) ?usize { + if (matched[i]) return null; + matched[i] = true; + return i; +} + +fn reconcileSources( + database: *db.Db, + arena: Allocator, + cfg: model.Config, + ids: *context.IdMap, + counts: *TableCounts, + doomed: *Doomed, +) Error!void { + const rows = try sources_repo.listSourceRows(database, arena); + const matched = try matchFlags(arena, rows.items.len); + + for (cfg.blocklist_sources) |item| { + if (findUnmatched(rows.items, matched, item, matchSource)) |i| { + const row = rows.items[i]; + // `updateSource` writes the four configuration columns only; the + // checksum, the counters and `last_updated` stay where the refresh + // path left them, which is the whole point of matching by url. + if (!std.mem.eql(u8, row.name, item.name) or + row.enabled != item.enabled or + row.is_suggested != item.is_suggested) + { + try sources_repo.updateSource(database, row.id, item); + counts.updated += 1; + } + try ids.put(arena, item.url, row.id); + } else { + const id = try sources_repo.insertSourceRow(database, item); + counts.inserted += 1; + try ids.put(arena, item.url, id); + } + } + + for (rows.items, matched) |row, hit| { + if (!hit) try doomed.sources.append(arena, row.id); + } +} + +fn matchSource(row: sources_repo.SourceRow, item: model.BlocklistSource) bool { + return std.mem.eql(u8, row.url, item.url); +} + +/// Clients are the one table where a row can predate the configuration: the DNS +/// path materialises `hand_edited = 0` rows straight from live traffic. +/// +/// * An observed row the file says nothing about is kept wholesale and counts +/// nothing. +/// * An observed row whose address the file now declares is **promoted in +/// place**: name, group and the flag are written, `first_seen` and +/// `last_seen` are not, and the row id survives. It counts as an update, +/// because a row was written. +/// * Only a formerly declared row (`hand_edited = 1`) the file dropped is +/// deleted. +fn reconcileClients( + database: *db.Db, + arena: Allocator, + cfg: model.Config, + ctx: context.InsertContext, + counts: *TableCounts, + doomed: *Doomed, +) Error!void { + const rows = try clients_repo.listClientRows(database, arena); + const matched = try matchFlags(arena, rows.items.len); + + for (cfg.clients) |client| { + var buf: [canonical_buf_len]u8 = undefined; + var canonical = client; + canonical.ip = try canonicalIp(client.ip, &buf); + const group_id = try ctx.groupId(client.group); + + if (findUnmatched(rows.items, matched, canonical, matchClient)) |i| { + const row = rows.items[i]; + if (!std.mem.eql(u8, row.name, client.name) or + row.group_id != group_id or + !row.hand_edited) + { + try clients_repo.updateClient(database, row.id, .{ + .name = client.name, + .group_id = group_id, + }); + counts.updated += 1; + } + } else { + try clients_repo.insertClient(database, canonical, ctx); + counts.inserted += 1; + } + } + + for (rows.items, matched) |row, hit| { + // An observed row is runtime state. The file cannot remove it — it can + // only declare an address, never un-see one — so absence from the file + // is not a reason to delete it. + if (!hit and row.hand_edited) try doomed.clients.append(arena, row.id); + } +} + +fn matchClient(row: clients_repo.ClientRow, client: model.Client) bool { + return std.mem.eql(u8, row.ip, client.ip); +} + +fn reconcileClientPrefixes( + database: *db.Db, + arena: Allocator, + cfg: model.Config, + ctx: context.InsertContext, + counts: *TableCounts, + doomed: *Doomed, +) Error!void { + const rows = try clients_repo.listClientPrefixRows(database, arena); + const matched = try matchFlags(arena, rows.items.len); + + for (cfg.client_prefixes) |entry| { + var buf: [canonical_buf_len]u8 = undefined; + var canonical = entry; + canonical.prefix = try canonicalPrefix(entry.prefix, &buf); + const group_id = try ctx.groupId(entry.group); + + if (findUnmatched(rows.items, matched, canonical, matchPrefix)) |i| { + const row = rows.items[i]; + if (row.group_id != group_id or row.priority != entry.priority) { + try clients_repo.updateClientPrefix(database, row.id, .{ + .prefix = canonical.prefix, + .group_id = group_id, + .priority = entry.priority, + }); + counts.updated += 1; + } + } else { + try clients_repo.insertClientPrefix(database, canonical, ctx); + counts.inserted += 1; + } + } + + for (rows.items, matched) |row, hit| { + if (!hit) try doomed.client_prefixes.append(arena, row.id); + } +} + +fn matchPrefix(row: clients_repo.ClientPrefixRow, entry: model.ClientPrefix) bool { + return std.mem.eql(u8, row.prefix, entry.prefix); +} + +/// `rules` has no natural key — nothing distinguishes two identical rules — so +/// the identity is the whole tuple and the match is a **multiset**: exact +/// duplicates pair off one for one, and a file carrying a rule twice keeps two +/// rows with two `created_at` stamps. `findUnmatched` consuming its match is +/// what makes that work. +fn reconcileRules( + database: *db.Db, + arena: Allocator, + cfg: model.Config, + ctx: context.InsertContext, + counts: *TableCounts, + doomed: *Doomed, +) Error!void { + const rows = try rules_repo.listRuleRows(database, arena); + const matched = try matchFlags(arena, rows.items.len); + + for (cfg.rules) |rule| { + const wanted: rules_repo.RuleInput = .{ + .group_id = try ctx.groupId(rule.group), + .pattern = rule.pattern, + .kind = rule.kind, + .action = rule.action, + }; + if (findUnmatched(rows.items, matched, wanted, matchRule)) |_| { + // The tuple *is* the declarative content, so a match is never a + // write and `created_at` never moves. + } else { + _ = try rules_repo.insertRuleRow(database, wanted, ctx.now); + counts.inserted += 1; + } + } + + for (rows.items, matched) |row, hit| { + if (!hit) try doomed.rules.append(arena, row.id); + } +} + +fn matchRule(row: rules_repo.RuleRow, wanted: rules_repo.RuleInput) bool { + return row.group_id == wanted.group_id and + row.kind == wanted.kind and + row.action == wanted.action and + std.mem.eql(u8, row.pattern, wanted.pattern); +} + +/// The pair is the whole row, so this table has no update case at all. +fn reconcileGroupSources( + database: *db.Db, + arena: Allocator, + cfg: model.Config, + ctx: context.InsertContext, + counts: *TableCounts, + doomed: *Doomed, +) Error!void { + const rows = try groups_repo.listGroupSourcePairs(database, arena); + const matched = try matchFlags(arena, rows.items.len); + + for (cfg.group_sources) |item| { + const wanted: groups_repo.GroupSourcePair = .{ + .group_id = try ctx.groupId(item.group), + .source_id = try ctx.sourceId(item.source_url), + }; + if (findUnmatched(rows.items, matched, wanted, matchGroupSource) == null) { + try groups_repo.insertGroupSource(database, item, ctx); + counts.inserted += 1; + } + } + + for (rows.items, matched) |row, hit| { + if (!hit) try doomed.group_sources.append(arena, row); + } +} + +fn matchGroupSource(row: groups_repo.GroupSourcePair, wanted: groups_repo.GroupSourcePair) bool { + return row.group_id == wanted.group_id and row.source_id == wanted.source_id; +} + +fn reconcileUpstreams( + database: *db.Db, + arena: Allocator, + cfg: model.Config, + ctx: context.InsertContext, + counts: *TableCounts, + doomed: *Doomed, +) Error!void { + const rows = try upstreams_repo.listUpstreamRows(database, arena); + const matched = try matchFlags(arena, rows.items.len); + + for (cfg.upstreams) |item| { + if (findUnmatched(rows.items, matched, item, matchUpstream)) |i| { + const row = rows.items[i]; + if (row.priority != item.priority or + row.enabled != item.enabled or + !std.mem.eql(u8, row.tls_name, item.tls_name)) + { + try upstreams_repo.updateUpstream(database, row.id, item); + counts.updated += 1; + } + } else { + try upstreams_repo.insertUpstream(database, item, ctx); + counts.inserted += 1; + } + } + + for (rows.items, matched) |row, hit| { + if (!hit) try doomed.upstreams.append(arena, row.id); + } +} + +fn matchUpstream(row: upstreams_repo.UpstreamRow, item: model.UpstreamServer) bool { + return std.mem.eql(u8, row.url, item.url); +} + +fn reconcileLocalRecords( + database: *db.Db, + arena: Allocator, + cfg: model.Config, + ctx: context.InsertContext, + counts: *TableCounts, + doomed: *Doomed, +) Error!void { + const rows = try local_repo.listLocalRecordRows(database, arena); + const matched = try matchFlags(arena, rows.items.len); + + for (cfg.local_records) |item| { + if (findUnmatched(rows.items, matched, item, matchLocalRecord)) |i| { + const row = rows.items[i]; + if (row.ttl != item.ttl) { + try local_repo.updateLocalRecord(database, row.id, item); + counts.updated += 1; + } + } else { + try local_repo.insertLocalRecord(database, item, ctx); + counts.inserted += 1; + } + } + + for (rows.items, matched) |row, hit| { + if (!hit) try doomed.local_records.append(arena, row.id); + } +} + +/// `UNIQUE(name, rtype, value)` — the DDL's own key, so the ttl is the only +/// declarative column an edit can touch. +fn matchLocalRecord(row: local_repo.LocalRecordRow, item: model.LocalRecord) bool { + return row.rtype == item.rtype and + std.mem.eql(u8, row.name, item.name) and + std.mem.eql(u8, row.value, item.value); +} + +fn reconcileForwardZones( + database: *db.Db, + arena: Allocator, + cfg: model.Config, + ctx: context.InsertContext, + counts: *TableCounts, + doomed: *Doomed, +) Error!void { + const rows = try local_repo.listForwardZoneRows(database, arena); + const matched = try matchFlags(arena, rows.items.len); + + for (cfg.forward_zones) |item| { + if (findUnmatched(rows.items, matched, item, matchForwardZone)) |i| { + const row = rows.items[i]; + if (!std.mem.eql(u8, row.resolver, item.resolver)) { + try local_repo.updateForwardZone(database, row.id, item); + counts.updated += 1; + } + } else { + try local_repo.insertForwardZone(database, item, ctx); + counts.inserted += 1; + } + } + + for (rows.items, matched) |row, hit| { + if (!hit) try doomed.forward_zones.append(arena, row.id); + } +} + +fn matchForwardZone(row: local_repo.ForwardZoneRow, item: model.ForwardZone) bool { + return std.mem.eql(u8, row.zone, item.zone); +} + +// --------------------------------------------------------------------------- +// settings, and the password rule +// --------------------------------------------------------------------------- + +fn reconcileSettings( + io: std.Io, + gpa: Allocator, + arena: Allocator, + database: *db.Db, + cfg: model.Config, + summary: *Summary, + doomed: *Doomed, + options: Options, +) Error!void { + const stored = try settings_repo.listSettings(database, arena); + + // `toSettings` no longer emits either password field (ruling 4), so what it + // produces is exactly the set of keys a configuration fully determines. + var pairs: std.ArrayList(model.SettingPair) = .empty; + try model.toSettings(cfg, arena, &pairs); + + for (pairs.items) |pair| { + if (storedValue(stored.items, pair.key)) |current| { + if (std.mem.eql(u8, current, pair.value)) continue; + try settings_repo.putSetting(database, pair.key, pair.value); + summary.settings.updated += 1; + } else { + try settings_repo.putSetting(database, pair.key, pair.value); + summary.settings.inserted += 1; + } + try recordSettingsKey(gpa, options, pair.key); + } + + try reconcilePassword(io, gpa, database, cfg, stored.items, summary, options); + + // The sweep. `web.password_hash` is the single exemption, because the + // engine owns that row and "the file said nothing" means keep it — a + // general sweep would read the file's silence as a deletion and open the + // admin UI to the LAN. + for (stored.items) |pair| { + if (std.mem.eql(u8, pair.key, password_hash_key)) continue; + if (hasKey(pairs.items, pair.key)) continue; + try doomed.settings.append(arena, pair.key); + } +} + +/// Ruling 4, the whole rule in one place. Presence is the question at every +/// branch, never emptiness — a file that states nothing keeps the stored hash, +/// and disabling authentication takes the explicit `password_hash = ""`. +fn reconcilePassword( + io: std.Io, + gpa: Allocator, + database: *db.Db, + cfg: model.Config, + stored: []const model.SettingPair, + summary: *Summary, + options: Options, +) Error!void { + if (cfg.web.password != null and cfg.web.password_hash != null) { + // `validate` rejects this first on every path an operator can reach. + // The engine repeats the check because it must never guess which of two + // contradictory security settings was meant. + return error.PasswordAndHashBothSet; + } + + const current = storedValue(stored, password_hash_key); + var hash_buf: [hash_buf_len]u8 = undefined; + + const desired: ?[]const u8 = desired: { + // A hash is written verbatim, `""` included: that is the documented way + // to turn authentication off. + if (cfg.web.password_hash) |hash| break :desired hash; + + if (cfg.web.password) |plain| { + // Verify against the stored hash and keep it on a match. Not a cost + // saving — argon2 verification recomputes the whole function with + // the stored salt and costs exactly what hashing costs — but the + // only way the file stays idempotent, since hashing generates a + // fresh salt every time. Never replace this with a cached-plaintext + // comparison; that would be a security bug. + if (current) |value| { + if (value.len != 0 and try verifyKeeps(io, gpa, value, plain)) break :desired value; + } + break :desired try hashPassword(io, gpa, plain, &hash_buf); + } + + // Silence means keep. + break :desired null; + }; + + const was_on = if (current) |value| value.len != 0 else false; + const now_on = if (desired) |value| value.len != 0 else was_on; + + if (desired) |value| { + if (current) |value_before| { + if (!std.mem.eql(u8, value_before, value)) { + try settings_repo.putSetting(database, password_hash_key, value); + summary.settings.updated += 1; + try recordSettingsKey(gpa, options, password_hash_key); + if (was_on and now_on) summary.auth_transition = .rotated; + } + } else { + try settings_repo.putSetting(database, password_hash_key, value); + summary.settings.inserted += 1; + try recordSettingsKey(gpa, options, password_hash_key); + } + } + + if (was_on != now_on) summary.auth_transition = if (now_on) .enabled else .disabled; +} + +fn storedValue(pairs: []const model.SettingPair, key: []const u8) ?[]const u8 { + for (pairs) |pair| { + if (std.mem.eql(u8, pair.key, key)) return pair.value; + } + return null; +} + +fn hasKey(pairs: []const model.SettingPair, key: []const u8) bool { + return storedValue(pairs, key) != null; +} + +fn recordSettingsKey(gpa: Allocator, options: Options, key: []const u8) Error!void { + const list = options.changed_settings orelse return; + const copy = try gpa.dupe(u8, key); + errdefer gpa.free(copy); + try list.append(gpa, copy); +} + +/// True when `plain` is the password behind `stored`, so the stored hash may be +/// kept as it is. +/// +/// A stored PHC string this build cannot read is not a match: the file's +/// password is the authority and a fresh hash replaces the unreadable one. The +/// reason is logged at `warn` — never the password, never the hash. +fn verifyKeeps(io: std.Io, gpa: Allocator, stored: []const u8, plain: []const u8) Error!bool { + std.crypto.pwhash.argon2.strVerify(stored, plain, .{ .allocator = gpa }, io) catch |e| switch (e) { + error.OutOfMemory => return error.OutOfMemory, + error.Canceled => return error.Canceled, + error.PasswordVerificationFailed => return false, + else => { + log.warn("the stored web.password_hash could not be verified ({s}); " ++ + "hashing the configured password instead", .{@errorName(e)}); + return false; + }, + }; + return true; +} + +/// argon2id with the OWASP parameters (t=2, m=19 MiB, p=1) — the same shape +/// `import` and `PUT /api/settings` produce, so a hash cannot say where it came +/// from. +fn hashPassword(io: std.Io, gpa: Allocator, plain: []const u8, buf: []u8) Error![]const u8 { + return std.crypto.pwhash.argon2.strHash(plain, .{ + .allocator = gpa, + .params = .owasp_2id, + .mode = .argon2id, + .encoding = .phc, + }, buf, io) catch |e| switch (e) { + error.OutOfMemory => error.OutOfMemory, + error.Canceled => error.Canceled, + else => { + log.warn("hashing web.password failed: {s}", .{@errorName(e)}); + return error.Unexpected; + }, + }; +} + +// --------------------------------------------------------------------------- +// matching +// --------------------------------------------------------------------------- + +fn matchFlags(arena: Allocator, len: usize) Allocator.Error![]bool { + const flags = try arena.alloc(bool, len); + @memset(flags, false); + return flags; +} + +/// The first row `equals` accepts that no earlier file entry has already +/// claimed, marked as claimed on the way out. +/// +/// Consuming the match is what makes `rules` a multiset rather than a set: two +/// identical file entries take two rows, and a third would insert. For every +/// other table the identity is unique, so the consumption is invisible. +fn findUnmatched( + rows: anytype, + matched: []bool, + wanted: anytype, + comptime equals: fn (@typeInfo(@TypeOf(rows)).pointer.child, @TypeOf(wanted)) bool, +) ?usize { + for (rows, 0..) |row, i| { + if (matched[i]) continue; + if (equals(row, wanted)) { + matched[i] = true; + return i; + } + } + return null; +} + +/// The validator compares client addresses after canonicalisation and the +/// column stores the canonical text, so the file's value is canonicalised +/// *before* it is matched. Matching the raw file string against the canonical +/// column would churn row ids under an unchanged non-canonical file, which is +/// exactly what ruling 5 forbids — and an exported-config test could never +/// catch it, because an export emits canonical forms. +fn canonicalIp(text: []const u8, buf: []u8) error{BadClientIp}![]const u8 { + const addr = address.NetAddress.parse(text) catch return error.BadClientIp; + var w: std.Io.Writer = .fixed(buf); + addr.format(&w) catch return error.BadClientIp; + return w.buffered(); +} + +fn canonicalPrefix(text: []const u8, buf: []u8) error{BadClientPrefix}![]const u8 { + const prefix = address.Prefix.parse(text) catch return error.BadClientPrefix; + var w: std.Io.Writer = .fixed(buf); + prefix.format(&w) catch return error.BadClientPrefix; + return w.buffered(); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; +const migrations = @import("../storage/migrations.zig"); +const validate = @import("validate.zig"); + +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; +} + +/// Every row of every config table, rendered in a stable order. Two dumps are +/// equal exactly when the database content is. +fn dump(database: *db.Db, gpa: Allocator) ![]u8 { + var out: std.Io.Writer.Allocating = .init(gpa); + errdefer out.deinit(); + const w = &out.writer; + + inline for (config_schema.table_names) |table| { + try w.print("{s}\n", .{table}); + var rows = try database.prepare("SELECT * FROM " ++ table ++ " ORDER BY 1, 2"); + defer rows.deinit(); + const columns = db.c.sqlite3_column_count(rows.handle); + while (try rows.step()) { + var col: c_int = 0; + while (col < columns) : (col += 1) { + // The type marker is what makes "equal dumps" mean "equal + // content": `columnText` renders SQL NULL as the empty string, + // so without it a nulled `clients.name` and an emptied one + // would compare equal and ruling 5 would be proved by a dump + // that cannot see the difference. + switch (db.c.sqlite3_column_type(rows.handle, col)) { + db.column_type.null_value => try w.writeAll(" null"), + db.column_type.integer => try w.print(" i:{d}", .{rows.columnInt(col)}), + db.column_type.blob => try w.print(" b:{s}", .{rows.columnText(col)}), + // Text, and float, which no config column declares. + else => try w.print(" t:{s}", .{rows.columnText(col)}), + } + } + try w.writeAll("\n"); + } + } + return out.toOwnedSlice(); +} + +/// Parses `source` and converges the database onto it, the way `run --config` +/// will. Validation runs first, exactly as it does in production, so a test +/// config that could never reach the engine fails here rather than silently +/// exercising an unreachable path. +fn applyText( + io: std.Io, + database: *db.Db, + arena: Allocator, + source: [:0]const u8, + now: i64, +) !Summary { + const cfg = try std.zon.parse.fromSliceAlloc(model.Config, arena, source, null, .{}); + var diags: validate.Diagnostics = .init(testing.allocator); + defer diags.deinit(); + try validate.validate(cfg, &diags); + var pass = try begin(io, testing.allocator, database, cfg, now, .{}); + errdefer pass.rollback(); + try pass.commit(); + return pass.summary; +} + +const minimal_source: [:0]const u8 = + \\.{ + \\ .groups = .{ .{ .name = "default" } }, + \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, + \\} +; + +/// Exercises every collection, with a non-canonical client address and a +/// plaintext password: the two inputs a naive engine churns on. +const full_source: [:0]const u8 = + \\.{ + \\ .dns = .{ .port = 5353 }, + \\ .web = .{ .password = "correct horse battery staple" }, + \\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } }, + \\ .upstreams = .{ + \\ .{ .url = "https://dns.example/dns-query", .priority = 10 }, + \\ .{ .url = "tls://192.0.2.53:853", .priority = 20, .enabled = false, .tls_name = "dot.example" }, + \\ }, + \\ .clients = .{ .{ .ip = "FD00:0:0:0:0:0:0:1", .name = "tablet", .group = "kids" } }, + \\ .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } }, + \\ .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "ads" } }, + \\ .group_sources = .{ .{ .group = "kids", .source_url = "https://lists.example/ads.txt" } }, + \\ .rules = .{ + \\ .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block }, + \\ .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block }, + \\ .{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow }, + \\ }, + \\ .local_records = .{ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 600 } }, + \\ .forward_zones = .{ .{ .zone = "lan", .resolver = "udp://192.168.1.1:53" } }, + \\} +; + +const Bench = struct { + threaded: std.Io.Threaded, + arena_state: std.heap.ArenaAllocator, + database: db.Db, + + fn init(self: *Bench) !void { + self.threaded = .init(testing.allocator, .{}); + self.arena_state = .init(testing.allocator); + self.database = try openMigrated(); + } + + fn deinit(self: *Bench) void { + self.database.close(); + self.arena_state.deinit(); + self.threaded.deinit(); + } + + fn io(self: *Bench) std.Io { + return self.threaded.io(); + } + + fn arena(self: *Bench) Allocator { + return self.arena_state.allocator(); + } + + fn apply(self: *Bench, source: [:0]const u8, now: i64) !Summary { + return applyText(self.io(), &self.database, self.arena(), source, now); + } +}; + +/// Seeds the runtime columns of every source, the way a completed refresh +/// would, so a test can prove the engine preserved them. +fn seedSourceStats(database: *db.Db, id: i64) !void { + return sources_repo.updateSourceStats(database, id, .{ + .last_updated = 1_700_000_000, + .domain_count = 4321, + .wildcard_count = 21, + .skipped_regex_count = 7, + .checksum = "a" ** 64, + }); +} + +test "reconciling the same configuration twice is byte-identical and writes nothing" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + const gpa = testing.allocator; + + // A plaintext password and a non-canonical v6 address are both in + // `full_source`: hashing generates a fresh salt per call and matching a raw + // file string against a canonical column churns row ids, so a naive engine + // fails this test on either input alone. + _ = try bench.apply(full_source, 1_700_000_000); + + const before = try dump(&bench.database, gpa); + defer gpa.free(before); + const changes_before = bench.database.totalChanges(); + + // A later clock, so anything that restamped a timestamp would show. + const summary = try bench.apply(full_source, 1_800_000_000); + + const after = try dump(&bench.database, gpa); + defer gpa.free(after); + + try testing.expectEqualStrings(before, after); + try testing.expect(summary.isNoOp()); + try testing.expectEqual(AuthTransition.none, summary.auth_transition); + // Stronger than byte-equality: an UPDATE that rewrote identical values + // would leave the dump equal and still move this counter. + try testing.expectEqual(changes_before, bench.database.totalChanges()); +} + +test "a source keeps its id, its checksum and its counters across a reconcile" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + const gpa = testing.allocator; + + _ = try bench.apply(full_source, 1_700_000_000); + + var seeded = try sources_repo.listSourceRows(&bench.database, gpa); + defer seeded.deinit(gpa); + defer sources_repo.freeSourceRows(gpa, seeded.items); + try testing.expectEqual(@as(usize, 1), seeded.items.len); + const id = seeded.items[0].id; + try seedSourceStats(&bench.database, id); + + // The name changes, which is a declarative edit; the url does not, so the + // identity holds and the compiled `.list` stays valid. + const renamed: [:0]const u8 = + \\.{ + \\ .groups = .{ .{ .name = "default" } }, + \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, + \\ .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "advertising" } }, + \\} + ; + const summary = try bench.apply(renamed, 1_800_000_000); + try testing.expectEqual(@as(u32, 1), summary.sources.updated); + try testing.expectEqual(@as(u32, 0), summary.sources.deleted); + try testing.expectEqual(@as(u32, 0), summary.sources.inserted); + + const row = (try sources_repo.getSource(&bench.database, gpa, id)).?; + defer sources_repo.freeSourceRow(gpa, row); + try testing.expectEqualStrings("advertising", row.name); + try testing.expectEqual(@as(?i64, 1_700_000_000), row.last_updated); + try testing.expectEqual(@as(i64, 4321), row.domain_count); + try testing.expectEqualStrings("a" ** 64, row.checksum.?); +} + +test "changing a source url is a new identity: new id, no runtime state" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + const gpa = testing.allocator; + + _ = try bench.apply(full_source, 1_700_000_000); + const old_id = try bench.database.queryInt( + "SELECT id FROM blocklist_sources WHERE url = 'https://lists.example/ads.txt'", + ); + try seedSourceStats(&bench.database, old_id); + + const moved: [:0]const u8 = + \\.{ + \\ .groups = .{ .{ .name = "default" } }, + \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, + \\ .blocklist_sources = .{ .{ .url = "https://lists.example/ads-v2.txt", .name = "ads" } }, + \\} + ; + const summary = try bench.apply(moved, 1_800_000_000); + try testing.expectEqual(@as(u32, 1), summary.sources.inserted); + try testing.expectEqual(@as(u32, 1), summary.sources.deleted); + + var rows = try sources_repo.listSourceRows(&bench.database, gpa); + defer rows.deinit(gpa); + defer sources_repo.freeSourceRows(gpa, rows.items); + try testing.expectEqual(@as(usize, 1), rows.items.len); + try testing.expect(rows.items[0].id != old_id); + // A fresh row means a fresh download, which is the accepted trade: the + // compiled artefacts are named after the id, so they cannot follow a url. + try testing.expectEqual(@as(?i64, null), rows.items[0].last_updated); + try testing.expectEqual(@as(?[]const u8, null), rows.items[0].checksum); +} + +test "a source the file stops declaring is removed" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + + _ = try bench.apply(full_source, 1_700_000_000); + const summary = try bench.apply(minimal_source, 1_800_000_000); + + try testing.expectEqual(@as(u32, 1), summary.sources.deleted); + try testing.expectEqual(@as(i64, 0), try sources_repo.countBlocklistSources(&bench.database)); + // The assignment went with it, counted by its own pass rather than left to + // the ON DELETE CASCADE. + try testing.expectEqual(@as(u32, 1), summary.group_sources.deleted); +} + +test "an observed client survives a reconcile that never mentions it" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + + try clients_repo.upsertSeen(&bench.database, "192.168.1.5", 1_700_000_000); + try clients_repo.upsertSeen(&bench.database, "192.168.1.5", 1_700_000_100); + + const summary = try bench.apply(minimal_source, 1_800_000_000); + try testing.expectEqual(@as(u32, 0), summary.clients.deleted); + try testing.expectEqual(@as(u32, 0), summary.clients.updated); + + var stmt = try bench.database.prepare( + "SELECT hand_edited, first_seen, last_seen FROM clients WHERE ip = '192.168.1.5'", + ); + defer stmt.deinit(); + try testing.expect(try stmt.step()); + try testing.expectEqual(@as(i64, 0), stmt.columnInt(0)); + try testing.expectEqual(@as(i64, 1_700_000_000), stmt.columnInt(1)); + try testing.expectEqual(@as(i64, 1_700_000_100), stmt.columnInt(2)); +} + +test "declaring an observed address promotes the row in place" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + + // Seen twice, so the two timestamps differ and no assertion below can pass + // by carrying one column into the other. `full_source` names this address, + // in a non-canonical spelling. + try clients_repo.upsertSeen(&bench.database, "fd00::1", 1_700_000_200); + try clients_repo.upsertSeen(&bench.database, "fd00::1", 1_700_000_900); + const id_before = try bench.database.queryInt("SELECT id FROM clients WHERE ip = 'fd00::1'"); + + const summary = try bench.apply(full_source, 1_800_000_000); + try testing.expectEqual(@as(u32, 1), summary.clients.updated); + try testing.expectEqual(@as(u32, 0), summary.clients.inserted); + try testing.expectEqual(@as(u32, 0), summary.clients.deleted); + + var stmt = try bench.database.prepare( + \\SELECT c.id, c.hand_edited, c.name, g.name, c.first_seen, c.last_seen + \\ FROM clients c JOIN groups g ON g.id = c.group_id WHERE c.ip = 'fd00::1' + ); + defer stmt.deinit(); + try testing.expect(try stmt.step()); + // The row id survives, so nothing keyed by it is orphaned. + try testing.expectEqual(id_before, stmt.columnInt(0)); + try testing.expectEqual(@as(i64, 1), stmt.columnInt(1)); + try testing.expectEqualStrings("tablet", stmt.columnText(2)); + try testing.expectEqualStrings("kids", stmt.columnText(3)); + // The observation history is the tracker's. The reconcile clock is far + // above both values, so a restamp would be unmissable. + try testing.expectEqual(@as(i64, 1_700_000_200), stmt.columnInt(4)); + try testing.expectEqual(@as(i64, 1_700_000_900), stmt.columnInt(5)); +} + +test "a declared client the file drops is removed, an observed one is not" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + + _ = try bench.apply(full_source, 1_700_000_000); + try clients_repo.upsertSeen(&bench.database, "10.0.0.9", 1_700_000_200); + + const summary = try bench.apply(minimal_source, 1_800_000_000); + try testing.expectEqual(@as(u32, 1), summary.clients.deleted); + + try testing.expectEqual(@as(i64, 0), try bench.database.queryInt( + "SELECT count(*) FROM clients WHERE ip = 'fd00::1'", + )); + try testing.expectEqual(@as(i64, 1), try bench.database.queryInt( + "SELECT count(*) FROM clients WHERE ip = '10.0.0.9' AND hand_edited = 0", + )); +} + +test "removing a group reassigns its observed clients to the default group" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + + _ = try bench.apply(full_source, 1_700_000_000); + + // A device the DNS path materialised, moved into `kids` the way a prefix + // rule or an operator would. `clients.group_id` has no ON DELETE action, so + // without the reassignment the group delete below trips the foreign key, + // the transaction aborts, and the box restart-loops on a file that `check` + // called valid. + try clients_repo.upsertSeen(&bench.database, "10.0.0.9", 1_700_000_200); + const kids = (try groups_repo.groupId(&bench.database, "kids")).?; + const observed = try bench.database.queryInt("SELECT id FROM clients WHERE ip = '10.0.0.9'"); + try bench.database.exec("UPDATE clients SET group_id = 2 WHERE ip = '10.0.0.9';"); + try testing.expectEqual(kids, try bench.database.queryInt( + "SELECT group_id FROM clients WHERE ip = '10.0.0.9'", + )); + + const summary = try bench.apply(minimal_source, 1_800_000_000); + try testing.expectEqual(@as(u32, 1), summary.groups.deleted); + + var stmt = try bench.database.prepare( + "SELECT id, group_id, hand_edited FROM clients WHERE ip = '10.0.0.9'", + ); + defer stmt.deinit(); + try testing.expect(try stmt.step()); + try testing.expectEqual(observed, stmt.columnInt(0)); + try testing.expectEqual(default_group_id, stmt.columnInt(1)); + // Reassignment is not a promotion: the device is still runtime state. + try testing.expectEqual(@as(i64, 0), stmt.columnInt(2)); +} + +test "renaming a group reassigns its observed clients rather than failing" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + + _ = try bench.apply(full_source, 1_700_000_000); + try clients_repo.upsertSeen(&bench.database, "10.0.0.9", 1_700_000_200); + try bench.database.exec("UPDATE clients SET group_id = 2 WHERE ip = '10.0.0.9';"); + + // A rename is a delete plus an insert to an engine that matches groups by + // name, so it walks into the same foreign key as a removal does. + const renamed: [:0]const u8 = + \\.{ + \\ .groups = .{ .{ .name = "default" }, .{ .name = "children", .safe_search = true } }, + \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, + \\} + ; + const summary = try bench.apply(renamed, 1_800_000_000); + try testing.expectEqual(@as(u32, 1), summary.groups.inserted); + try testing.expectEqual(@as(u32, 1), summary.groups.deleted); + + try testing.expectEqual(default_group_id, try bench.database.queryInt( + "SELECT group_id FROM clients WHERE ip = '10.0.0.9'", + )); +} + +test "editing safe_search on an existing group converges without moving its id" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + + _ = try bench.apply(full_source, 1_700_000_000); + const kids = (try groups_repo.groupId(&bench.database, "kids")).?; + + const relaxed: [:0]const u8 = + \\.{ + \\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = false } }, + \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, + \\} + ; + const summary = try bench.apply(relaxed, 1_800_000_000); + try testing.expectEqual(@as(u32, 1), summary.groups.updated); + try testing.expectEqual(@as(u32, 0), summary.groups.inserted); + try testing.expectEqual(@as(u32, 0), summary.groups.deleted); + + try testing.expectEqual(kids, (try groups_repo.groupId(&bench.database, "kids")).?); + try testing.expectEqual(@as(i64, 0), try bench.database.queryInt( + "SELECT safe_search FROM groups WHERE name = 'kids'", + )); +} + +test "the default group keeps id 1 across every reconcile" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + + _ = try bench.apply(full_source, 1_700_000_000); + try testing.expectEqual(default_group_id, (try groups_repo.groupId(&bench.database, "default")).?); + _ = try bench.apply(minimal_source, 1_800_000_000); + try testing.expectEqual(default_group_id, (try groups_repo.groupId(&bench.database, "default")).?); +} + +test "the default group keeps id 1 however late the file declares it" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + + // File order is the operator's, not the engine's. A pass that inserted + // groups in file order onto a fresh database — rather than matching + // `default` to the row migration step 1 already seeded — would hand id 2 to + // `default` here and trip the pin, so this is the ordering the engine has + // to be indifferent to. + const default_last: [:0]const u8 = + \\.{ + \\ .groups = .{ + \\ .{ .name = "kids", .safe_search = true }, + \\ .{ .name = "guests" }, + \\ .{ .name = "default" }, + \\ }, + \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, + \\} + ; + const summary = try bench.apply(default_last, 1_700_000_000); + try testing.expectEqual(@as(u32, 2), summary.groups.inserted); + try testing.expectEqual(@as(u32, 0), summary.groups.updated); + + try testing.expectEqual(default_group_id, (try groups_repo.groupId(&bench.database, "default")).?); + // And the two new groups took ids of their own rather than colliding. + try testing.expect((try groups_repo.groupId(&bench.database, "kids")).? != default_group_id); + try testing.expect((try groups_repo.groupId(&bench.database, "guests")).? != default_group_id); + + // Re-applying the same file is still a no-op, which it would not be if the + // first pass had settled the ids by luck. + const again = try bench.apply(default_last, 1_800_000_000); + try testing.expect(again.isNoOp()); +} + +test "a database whose group 1 is not 'default' is refused, not renumbered" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + const gpa = testing.allocator; + + // The API refuses to rename or delete the default group and migration + // step 1 seeds it, so only a hand-edited database reaches this state. It + // must fail loudly: `upsertSeen` and §7.2's fallback both resolve an + // unassigned client to group 1, so converging onto a file that puts + // `default` somewhere else would silently move every such device. + try bench.database.exec("UPDATE groups SET name = 'renamed' WHERE id = 1;"); + const before = try dump(&bench.database, gpa); + defer gpa.free(before); + + const cfg = try std.zon.parse.fromSliceAlloc(model.Config, bench.arena(), minimal_source, null, .{}); + try testing.expectError( + error.Unexpected, + begin(bench.io(), gpa, &bench.database, cfg, 1_700_000_000, .{}), + ); + + const after = try dump(&bench.database, gpa); + defer gpa.free(after); + try testing.expectEqualStrings(before, after); +} + +test "a database with no group 1 is refused rather than reseeded" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + const gpa = testing.allocator; + + // The empty table is the dangerous shape, not the merely renamed one: the + // file declares `default`, and an engine that treats it as an ordinary + // desired group inserts it, collects id 1 from a table whose rowids start + // over, and reports success while having quietly reseeded the row the + // migration owns. + try bench.database.exec("DELETE FROM groups;"); + + const cfg = try std.zon.parse.fromSliceAlloc(model.Config, bench.arena(), minimal_source, null, .{}); + try testing.expectError( + error.Unexpected, + begin(bench.io(), gpa, &bench.database, cfg, 1_700_000_000, .{}), + ); + + // Refused means refused: no group was created on the way out. + try testing.expectEqual(@as(i64, 0), try bench.database.queryInt("SELECT count(*) FROM groups")); +} + +test "recording a settings key frees the copy the list could not take" { + // `testing.allocator` fails this test if the duped key outlives the failed + // append, which is the whole assertion. + var failing: std.testing.FailingAllocator = .init(testing.allocator, .{ .fail_index = 1 }); + const gpa = failing.allocator(); + + var changed: std.ArrayList([]const u8) = .empty; + defer changed.deinit(gpa); + + // The dupe is allocation 0 and succeeds; growing the list is allocation 1 + // and does not. + try testing.expectError( + error.OutOfMemory, + recordSettingsKey(gpa, .{ .changed_settings = &changed }, "dns.port"), + ); + try testing.expectEqual(@as(usize, 0), changed.items.len); +} + +test "the dump tells SQL NULL apart from the empty string" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + const gpa = testing.allocator; + + // `clients.name` is nullable, so this pair of states is reachable. Ruling 5 + // is proved by comparing dumps, and a dump that renders both as nothing + // would call these two databases identical. + _ = try bench.apply(minimal_source, 1_700_000_000); + try bench.database.exec( + \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen) + \\VALUES ('10.0.0.5', NULL, 1, 0, 1700000000, 1700000000); + , + ); + const with_null = try dump(&bench.database, gpa); + defer gpa.free(with_null); + + try bench.database.exec("UPDATE clients SET name = '' WHERE ip = '10.0.0.5';"); + const with_empty = try dump(&bench.database, gpa); + defer gpa.free(with_empty); + + try testing.expect(!std.mem.eql(u8, with_null, with_empty)); +} + +test "rules keep created_at across a reconcile, duplicates included" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + const gpa = testing.allocator; + + // `full_source` carries the same wildcard rule twice: the identity is a + // multiset, so both rows must survive with both stamps. + _ = try bench.apply(full_source, 1_700_000_000); + + var before = try rules_repo.listRuleRows(&bench.database, gpa); + defer before.deinit(gpa); + defer rules_repo.freeRuleRows(gpa, before.items); + try testing.expectEqual(@as(usize, 3), before.items.len); + for (before.items) |row| try testing.expectEqual(@as(i64, 1_700_000_000), row.created_at); + + const summary = try bench.apply(full_source, 1_800_000_000); + try testing.expectEqual(@as(u32, 0), summary.rules.total()); + + var after = try rules_repo.listRuleRows(&bench.database, gpa); + defer after.deinit(gpa); + defer rules_repo.freeRuleRows(gpa, after.items); + try testing.expectEqual(before.items.len, after.items.len); + for (before.items, after.items) |old, new| { + try testing.expectEqual(old.id, new.id); + // The second pass ran with a clock 100 million seconds later. A rule + // restamped by an edit-in-place would read 1_800_000_000 here. + try testing.expectEqual(@as(i64, 1_700_000_000), new.created_at); + } +} + +test "dropping one of two identical rules removes exactly one row" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + + _ = try bench.apply(full_source, 1_700_000_000); + + const single: [:0]const u8 = + \\.{ + \\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } }, + \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, + \\ .rules = .{ + \\ .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block }, + \\ }, + \\} + ; + const summary = try bench.apply(single, 1_800_000_000); + try testing.expectEqual(@as(u32, 2), summary.rules.deleted); + try testing.expectEqual(@as(u32, 0), summary.rules.inserted); + try testing.expectEqual(@as(i64, 1), try rules_repo.countRules(&bench.database)); +} + +test "a plaintext password is hashed once and then verified and kept" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + + const first = try bench.apply(full_source, 1_700_000_000); + try testing.expectEqual(AuthTransition.enabled, first.auth_transition); + + var stmt = try bench.database.prepare("SELECT value FROM settings WHERE key = 'web.password_hash'"); + defer stmt.deinit(); + try testing.expect(try stmt.step()); + const hash = try testing.allocator.dupe(u8, stmt.columnText(0)); + defer testing.allocator.free(hash); + try testing.expect(std.mem.startsWith(u8, hash, "$argon2id$")); + + // The plaintext is never a row. + try testing.expectEqual(@as(i64, 0), try bench.database.queryInt( + "SELECT count(*) FROM settings WHERE key = 'web.password'", + )); + + // Second pass: hashing again would mint a fresh salt and a different PHC + // string, so keeping the stored hash is what makes the file idempotent. + const second = try bench.apply(full_source, 1_800_000_000); + try testing.expectEqual(AuthTransition.none, second.auth_transition); + try testing.expectEqual(@as(u32, 0), second.settings.total()); + try testing.expectEqualStrings(hash, try storedHash(&bench.database, bench.arena())); +} + +test "a changed plaintext password rehashes and reports a rotation" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + + _ = try bench.apply(full_source, 1_700_000_000); + const before = try testing.allocator.dupe(u8, try storedHash(&bench.database, bench.arena())); + defer testing.allocator.free(before); + + const changed: [:0]const u8 = + \\.{ + \\ .web = .{ .password = "a different passphrase entirely" }, + \\ .groups = .{ .{ .name = "default" } }, + \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, + \\} + ; + const summary = try bench.apply(changed, 1_800_000_000); + try testing.expectEqual(AuthTransition.rotated, summary.auth_transition); + + const after = try storedHash(&bench.database, bench.arena()); + try testing.expect(!std.mem.eql(u8, before, after)); + try testing.expect(std.mem.startsWith(u8, after, "$argon2id$")); +} + +test "a file that states no password leaves the stored hash and auth alone" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + + _ = try bench.apply(full_source, 1_700_000_000); + const before = try testing.allocator.dupe(u8, try storedHash(&bench.database, bench.arena())); + defer testing.allocator.free(before); + + // The trap this rule exists for: an operator trims the ugly PHC string out + // of an exported file, meaning "keep the current password". Reading that + // silence as `""` would open the admin UI to the LAN. + const summary = try bench.apply(minimal_source, 1_800_000_000); + try testing.expectEqual(AuthTransition.none, summary.auth_transition); + try testing.expectEqualStrings(before, try storedHash(&bench.database, bench.arena())); + + var cfg: model.Config = .{}; + var unknown: usize = 0; + const stored = try settings_repo.listSettings(&bench.database, bench.arena()); + try model.fromSettings(stored.items, &cfg, &unknown); + try testing.expect(cfg.web.password_hash != null); +} + +test "an explicit empty password_hash disables authentication and says so" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + + _ = try bench.apply(full_source, 1_700_000_000); + + const disabled: [:0]const u8 = + \\.{ + \\ .web = .{ .password_hash = "" }, + \\ .groups = .{ .{ .name = "default" } }, + \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, + \\} + ; + const summary = try bench.apply(disabled, 1_800_000_000); + try testing.expectEqual(AuthTransition.disabled, summary.auth_transition); + try testing.expectEqualStrings("", try storedHash(&bench.database, bench.arena())); + + // And it stays disabled without churning the row. + const again = try bench.apply(disabled, 1_900_000_000); + try testing.expectEqual(AuthTransition.none, again.auth_transition); + try testing.expectEqual(@as(u32, 0), again.settings.total()); +} + +test "a password_hash the file states verbatim is written verbatim" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + + const hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaGhhc2g"; + const literal: [:0]const u8 = + \\.{ + \\ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaGhhc2g" }, + \\ .groups = .{ .{ .name = "default" } }, + \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, + \\} + ; + const summary = try bench.apply(literal, 1_700_000_000); + try testing.expectEqual(AuthTransition.enabled, summary.auth_transition); + try testing.expectEqualStrings(hash, try storedHash(&bench.database, bench.arena())); + + const again = try bench.apply(literal, 1_800_000_000); + try testing.expectEqual(@as(u32, 0), again.settings.total()); +} + +test "a password and a password_hash together are refused" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + const gpa = testing.allocator; + + _ = try bench.apply(minimal_source, 1_700_000_000); + const before = try dump(&bench.database, gpa); + defer gpa.free(before); + + const both: model.Config = .{ + .groups = &.{.{ .name = "default" }}, + .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, + .web = .{ .password = "plaintext", .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" }, + }; + try testing.expectError( + error.PasswordAndHashBothSet, + begin(bench.io(), gpa, &bench.database, both, 1_800_000_000, .{}), + ); + + const after = try dump(&bench.database, gpa); + defer gpa.free(after); + try testing.expectEqualStrings(before, after); +} + +test "the caller sees the delete counts before it decides, and can still refuse" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + const gpa = testing.allocator; + + _ = try bench.apply(full_source, 1_700_000_000); + const before = try dump(&bench.database, gpa); + defer gpa.free(before); + + // `import`'s diff gate (ruling 6), written the way R2 will write it: read + // the counts, then decide. The read happens inside the same + // `BEGIN IMMEDIATE` that produced them, so no other writer can change the + // database between the count and the verdict. + const cfg = try std.zon.parse.fromSliceAlloc(model.Config, bench.arena(), minimal_source, null, .{}); + { + var pass = try begin(bench.io(), gpa, &bench.database, cfg, 1_800_000_000, .{}); + errdefer pass.rollback(); + try testing.expect(pass.summary.anyDeletes()); + try testing.expectEqual(@as(u32, 1), pass.summary.groups.deleted); + pass.rollback(); + } + + // Refusing costs the database nothing: every write the passes made on the + // way to the count went with the transaction. + // + // The dump is the proof here, not `totalChanges`: that counter is the + // connection's and counts rows a statement touched whether or not the + // transaction survived, so it can only ever show that nothing was + // *attempted* — which is the no-op test above, not this one. + const after = try dump(&bench.database, gpa); + defer gpa.free(after); + try testing.expectEqualStrings(before, after); + + // And the same pass, allowed, applies. + { + var pass = try begin(bench.io(), gpa, &bench.database, cfg, 1_800_000_000, .{}); + errdefer pass.rollback(); + try pass.commit(); + } + const applied = try dump(&bench.database, gpa); + defer gpa.free(applied); + try testing.expect(!std.mem.eql(u8, before, applied)); + try testing.expectEqual(@as(i64, 0), try sources_repo.countBlocklistSources(&bench.database)); +} + +test "a failure mid-transaction rolls the whole pass back" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + const gpa = testing.allocator; + + _ = try bench.apply(full_source, 1_700_000_000); + try clients_repo.upsertSeen(&bench.database, "10.0.0.9", 1_700_000_200); + const before = try dump(&bench.database, gpa); + defer gpa.free(before); + + // Two identical local records violate `UNIQUE(name, rtype, value)`. The + // validator would catch this, which is exactly why the test bypasses it: + // the all-or-nothing guarantee has to hold on its own, including for the + // rows the passes before this one already wrote. + const broken: model.Config = .{ + .groups = &.{.{ .name = "default" }}, + .upstreams = &.{.{ .url = "https://other.example/dns-query" }}, + .local_records = &.{ + .{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" }, + .{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" }, + }, + }; + try testing.expectError( + error.Constraint, + begin(bench.io(), gpa, &bench.database, broken, 1_800_000_000, .{}), + ); + + const after = try dump(&bench.database, gpa); + defer gpa.free(after); + try testing.expectEqualStrings(before, after); +} + +test "the summary names the settings keys that changed and never their values" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + const gpa = testing.allocator; + + _ = try bench.apply(full_source, 1_700_000_000); + + var changed: std.ArrayList([]const u8) = .empty; + defer { + for (changed.items) |key| gpa.free(key); + changed.deinit(gpa); + } + + const cfg = try std.zon.parse.fromSliceAlloc(model.Config, bench.arena(), + \\.{ + \\ .dns = .{ .port = 5300 }, + \\ .groups = .{ .{ .name = "default" } }, + \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, + \\} + , null, .{}); + var pass = try begin(bench.io(), gpa, &bench.database, cfg, 1_800_000_000, .{ + .changed_settings = &changed, + }); + errdefer pass.rollback(); + try pass.commit(); + const summary = pass.summary; + + try testing.expectEqual(@as(u32, 1), summary.settings.updated); + var saw_port = false; + for (changed.items) |key| { + if (std.mem.eql(u8, key, "dns.port")) saw_port = true; + try testing.expect(!std.mem.eql(u8, key, "web.password")); + } + try testing.expect(saw_port); +} + +test "a settings key the model no longer produces is swept, and the hash is not" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + + _ = try bench.apply(full_source, 1_700_000_000); + // A key from a newer binary, left behind by a downgrade. + try settings_repo.putSetting(&bench.database, "future.setting", "whatever"); + + const summary = try bench.apply(full_source, 1_800_000_000); + try testing.expectEqual(@as(u32, 1), summary.settings.deleted); + try testing.expectEqual(@as(i64, 0), try bench.database.queryInt( + "SELECT count(*) FROM settings WHERE key = 'future.setting'", + )); + // The one exemption survived the sweep, and authentication with it. + try testing.expectEqual(@as(i64, 1), try bench.database.queryInt( + "SELECT count(*) FROM settings WHERE key = 'web.password_hash'", + )); + try testing.expectEqual(AuthTransition.none, summary.auth_transition); +} + +test "reconciling an empty database from a full file inserts every table" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + + const summary = try bench.apply(full_source, 1_700_000_000); + + // `default` is already there from migration step 1, so only `kids` is new. + try testing.expectEqual(@as(u32, 1), summary.groups.inserted); + try testing.expectEqual(@as(u32, 1), summary.sources.inserted); + try testing.expectEqual(@as(u32, 1), summary.clients.inserted); + try testing.expectEqual(@as(u32, 1), summary.client_prefixes.inserted); + try testing.expectEqual(@as(u32, 3), summary.rules.inserted); + try testing.expectEqual(@as(u32, 1), summary.group_sources.inserted); + try testing.expectEqual(@as(u32, 2), summary.upstreams.inserted); + try testing.expectEqual(@as(u32, 1), summary.local_records.inserted); + try testing.expectEqual(@as(u32, 1), summary.forward_zones.inserted); + try testing.expect(summary.settings.inserted > 0); + try testing.expect(summary.anyDeletes() == false); + + // The v6 client was written canonical, not as the file spelled it. + try testing.expectEqual(@as(i64, 1), try bench.database.queryInt( + "SELECT count(*) FROM clients WHERE ip = 'fd00::1'", + )); +} + +test "every table loses the rows the file stopped declaring, and reports each" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + + _ = try bench.apply(full_source, 1_700_000_000); + const summary = try bench.apply(minimal_source, 1_800_000_000); + + // One assertion per table, because each delete pass is its own loop and a + // missing one would otherwise leave rows behind that nothing counted. + try testing.expectEqual(@as(u32, 1), summary.groups.deleted); + try testing.expectEqual(@as(u32, 1), summary.sources.deleted); + try testing.expectEqual(@as(u32, 1), summary.clients.deleted); + try testing.expectEqual(@as(u32, 1), summary.client_prefixes.deleted); + try testing.expectEqual(@as(u32, 3), summary.rules.deleted); + try testing.expectEqual(@as(u32, 1), summary.group_sources.deleted); + try testing.expectEqual(@as(u32, 1), summary.upstreams.deleted); + try testing.expectEqual(@as(u32, 1), summary.local_records.deleted); + try testing.expectEqual(@as(u32, 1), summary.forward_zones.deleted); + + // And the rows are actually gone, not merely counted. + try testing.expectEqual(@as(i64, 1), try groups_repo.countGroups(&bench.database)); + try testing.expectEqual(@as(i64, 0), try sources_repo.countBlocklistSources(&bench.database)); + try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&bench.database)); + try testing.expectEqual(@as(i64, 0), try clients_repo.countClientPrefixes(&bench.database)); + try testing.expectEqual(@as(i64, 0), try rules_repo.countRules(&bench.database)); + try testing.expectEqual(@as(i64, 0), try groups_repo.countGroupSources(&bench.database)); + try testing.expectEqual(@as(i64, 1), try upstreams_repo.countUpstreams(&bench.database)); + try testing.expectEqual(@as(i64, 0), try local_repo.countLocalRecords(&bench.database)); + try testing.expectEqual(@as(i64, 0), try local_repo.countForwardZones(&bench.database)); +} + +test "editing an upstream, a local record and a forward zone writes only those rows" { + var bench: Bench = undefined; + try bench.init(); + defer bench.deinit(); + + _ = try bench.apply(full_source, 1_700_000_000); + + const edited: [:0]const u8 = + \\.{ + \\ .dns = .{ .port = 5353 }, + \\ .web = .{ .password = "correct horse battery staple" }, + \\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } }, + \\ .upstreams = .{ + \\ .{ .url = "https://dns.example/dns-query", .priority = 5 }, + \\ .{ .url = "tls://192.0.2.53:853", .priority = 20, .enabled = false, .tls_name = "dot.example" }, + \\ }, + \\ .clients = .{ .{ .ip = "FD00:0:0:0:0:0:0:1", .name = "tablet", .group = "kids" } }, + \\ .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 10 } }, + \\ .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "ads" } }, + \\ .group_sources = .{ .{ .group = "kids", .source_url = "https://lists.example/ads.txt" } }, + \\ .rules = .{ + \\ .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block }, + \\ .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block }, + \\ .{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow }, + \\ }, + \\ .local_records = .{ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 60 } }, + \\ .forward_zones = .{ .{ .zone = "lan", .resolver = "udp://192.168.1.2:53" } }, + \\} + ; + const summary = try bench.apply(edited, 1_800_000_000); + + try testing.expectEqual(@as(u32, 1), summary.upstreams.updated); + try testing.expectEqual(@as(u32, 1), summary.client_prefixes.updated); + try testing.expectEqual(@as(u32, 1), summary.local_records.updated); + try testing.expectEqual(@as(u32, 1), summary.forward_zones.updated); + // Everything else stayed as it was: an edit is not a rewrite of the file. + try testing.expectEqual(@as(u32, 0), summary.groups.total()); + try testing.expectEqual(@as(u32, 0), summary.sources.total()); + try testing.expectEqual(@as(u32, 0), summary.clients.total()); + try testing.expectEqual(@as(u32, 0), summary.rules.total()); + try testing.expectEqual(@as(u32, 0), summary.settings.total()); + try testing.expect(!summary.anyDeletes()); +} + +fn storedHash(database: *db.Db, arena: Allocator) ![]const u8 { + const pairs = try settings_repo.listSettings(database, arena); + return storedValue(pairs.items, password_hash_key) orelse error.TestUnexpectedResult; +} diff --git a/src/config/validate.zig b/src/config/validate.zig index 1952cb8..fa52df2 100644 --- a/src/config/validate.zig +++ b/src/config/validate.zig @@ -101,6 +101,7 @@ pub const ValidateError = error{ MissingKeyPath, MissingLogPath, PasswordAndHashBothSet, + EmptyWebPassword, }; /// What `validate` returns: a verdict on the configuration, or the allocation @@ -114,7 +115,19 @@ pub const Error = ValidateError || Allocator.Error; /// same channel so a syntax error's line/column reaches the operator's output, /// plus the warnings — which are never returned by `validate` and so are not /// `ValidateError` members. -pub const ProblemError = ValidateError || error{ ParseZon, SourceInNoGroup }; +/// Wider than `ValidateError`: the diagnostic channel also carries the problems +/// found before the validator ever sees a `Config` — the ZON parse, the managed +/// file that would not open (`config/loader.zig`), the file above the size limit +/// — and the one found after it, an import whose diff would delete rows. The +/// validator itself records only `ValidateError` members, which is what makes +/// `validate`'s `@errorCast` of its own findings checked-safe. +pub const ProblemError = ValidateError || error{ + ParseZon, + SourceInNoGroup, + ManagedConfigUnreadable, + ConfigTooLarge, + DestructiveImport, +}; /// `.fail` rejects the configuration and is what an exit code is computed from. /// `.warn` reports something legal that is almost certainly not what the @@ -392,7 +405,10 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void { try checkBind(diags, cfg.web.bind, "web.bind", .any); try checkPort(diags, cfg.web.port, "web.port"); - if (cfg.web.password.len != 0 and cfg.web.password_hash.len != 0) { + // Both fields are optional, and absence is the third state: a file that + // states neither keeps the stored hash. So the test is on presence, not on + // length. + if (cfg.web.password != null and cfg.web.password_hash != null) { try diags.add( error.PasswordAndHashBothSet, "web.password", @@ -401,6 +417,22 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void { .{}, ); } + // A present-but-empty password would hash the empty string into a non-empty + // PHC — authentication on — while every login with an empty password is + // refused: authentication on and unreachable. The remedy is named, because + // the operator who wrote this meant one of two other things. + if (cfg.web.password) |password| { + if (password.len == 0) { + try diags.add( + error.EmptyWebPassword, + "web.password", + .{}, + "password is set to the empty string; omit the field to keep the stored password, " ++ + "or set password_hash = \"\" to disable authentication", + .{}, + ); + } + } // A session TTL is a TTL; `BadTtl` is its bucket. if (cfg.web.session_ttl_hours < 1) { try diags.add(error.BadTtl, "web.session_ttl_hours", .{}, "must be at least 1", .{}); @@ -1968,6 +2000,28 @@ test "error.PasswordAndHashBothSet" { try expectProblem(cfg, error.PasswordAndHashBothSet, "web.password"); } +test "error.EmptyWebPassword names password_hash as the way to disable auth" { + var cfg = baseConfig(); + cfg.web.password = ""; + try expectProblem(cfg, error.EmptyWebPassword, "web.password"); + + // The remedy has to be in the text: the operator who wrote `password = ""` + // meant either "keep the current one" or "turn authentication off", and the + // diagnostic is the only place that distinction is spelled out. + var diags: Diagnostics = .init(testing.allocator); + defer diags.deinit(); + try testing.expectError(error.EmptyWebPassword, validate(cfg, &diags)); + try testing.expect(std.mem.indexOf(u8, diags.problems.items[0].message, "password_hash = \"\"") != null); + + // Absence is not emptiness: a file that states no password is legal and + // means "keep the stored hash". + var absent = baseConfig(); + absent.web.password = null; + var quiet: Diagnostics = .init(testing.allocator); + defer quiet.deinit(); + try validate(absent, &quiet); +} + test "a config with five distinct problems yields five diagnostics and the first error" { var cfg = baseConfig(); cfg.dns.port = 0; // BadPort, first in check order diff --git a/src/filter/filter_integration_test.zig b/src/filter/filter_integration_test.zig index 928d63a..e54207b 100644 --- a/src/filter/filter_integration_test.zig +++ b/src/filter/filter_integration_test.zig @@ -22,6 +22,7 @@ const build_options = @import("build_options"); const net = std.Io.net; const model = @import("../config/model.zig"); +const reconcile = @import("../config/reconcile.zig"); const db = @import("../storage/db.zig"); const migrations = @import("../storage/migrations.zig"); const context = @import("../storage/repositories/context.zig"); @@ -400,6 +401,10 @@ const HttpFixture = struct { server: net.Server, body: []const u8, route: std.atomic.Value(u8), + /// Connections accepted, whatever came over them. A test that claims a pass + /// downloaded nothing reads this rather than the route counters: a refetch + /// that failed on the wire is still a refetch, and this counts it. + accepted: std.atomic.Value(u32), /// How many parts the `chunked` route has flushed. The test reads it to /// prove the reply really left this server in pieces, because a `Writer` /// reports a buffered part as written and would otherwise hide a fixture @@ -422,6 +427,7 @@ const HttpFixture = struct { .server = try local.listen(io, .{ .reuse_address = true }), .body = body, .route = .init(@intFromEnum(Route.body)), + .accepted = .init(0), .flushed_parts = .init(0), .stall_reached = .unset, .stall_release = .unset, @@ -449,6 +455,7 @@ const HttpFixture = struct { while (true) { var stream = self.server.accept(io) catch return; defer stream.close(io); + _ = self.accepted.fetchAdd(1, .monotonic); var read_buf: [8192]u8 = undefined; var write_buf: [8192]u8 = undefined; @@ -1372,6 +1379,112 @@ test "10d: a source deleted mid-refresh does not take the refresh's temporary fi } } +// --------------------------------------------------------------------------- +// 10e: the restart invariant, across the config engine and the filter layer +// --------------------------------------------------------------------------- + +test "10e: a reconcile then a restart reuses the compiled files and downloads nothing" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + const env = try Env.create(gpa); + defer env.destroy(); + const io = env.io(); + + var fixture = try HttpFixture.init(io, http_body); + defer fixture.deinit(io); + var group: std.Io.Group = .init; + defer group.cancel(io); + try group.concurrent(io, HttpFixture.serve, .{ &fixture, io }); + + var url_buf: [64]u8 = undefined; + const url = try fixture.url(&url_buf); + const id = try seedSource(&env.database, url); + + // One real download, so the compiled artifacts exist and are named after + // the row id the rest of this test is about. + try testing.expect(try refreshOnce(env, url)); + try testing.expectEqual(@as(u32, 1), fixture.accepted.load(.monotonic)); + + var dir = try env.blocklistDir(); + defer dir.close(io); + var list_buf: [64]u8 = undefined; + var wild_buf: [64]u8 = undefined; + const list_name = try std.fmt.bufPrint(&list_buf, "{d}.list", .{id}); + const wild_name = try std.fmt.bufPrint(&wild_buf, "{d}.wild", .{id}); + const list_before = try dir.statFile(io, list_name, .{}); + const wild_before = try dir.statFile(io, wild_name, .{}); + + // File mode, declaring exactly what the database already holds. The engine + // has to recognise the source by its url and leave the row where it is: + // the compiled files are named after that id, and the manager looks for + // them under the same number. + const cfg: model.Config = .{ + .groups = &.{.{ .name = "default" }}, + .blocklist_sources = &.{.{ .url = url, .name = source_name }}, + .group_sources = &.{.{ .group = "default", .source_url = url }}, + .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, + }; + var pass = try reconcile.begin( + io, + gpa, + &env.database, + cfg, + std.Io.Clock.real.now(io).toSeconds(), + .{}, + ); + errdefer pass.rollback(); + // Committed before the manager comes up, which is the ordering the invariant + // rests on: a manager that read the table mid-transaction could see either + // half of a source it is about to look for on disk. + try pass.commit(); + + // The restart. The old manager is gone and a new one comes up over the same + // directory and the same database with nothing carried across in memory. + // `runScheduler` is the boot sequence the server runs — the orphan sweep, + // then the startup pass — and a disabled update makes it return rather than + // wait out an interval. + env.mgr.deinit(io); + env.mgr = try manager.Manager.init( + gpa, + &env.database, + .{ .dir = env.tmp.dir }, + &env.f, + .{ .enabled = false }, + budget, + ); + try env.mgr.runScheduler(io); + + // Nothing was downloaded. The server is still listening, so this is a + // decision the pass made rather than a connection it could not have opened. + try testing.expectEqual(@as(u32, 1), fixture.accepted.load(.monotonic)); + + // The same two files: not recompiled, and not swept as orphans and written + // back. + const list_after = try dir.statFile(io, list_name, .{}); + const wild_after = try dir.statFile(io, wild_name, .{}); + try testing.expectEqual(list_before.inode, list_after.inode); + try testing.expectEqual(list_before.mtime, list_after.mtime); + try testing.expectEqual(wild_before.inode, wild_after.inode); + try testing.expectEqual(wild_before.mtime, wild_after.mtime); + + // The row kept the id those files are named after, and the snapshot the + // restart published is the one compiled from them. + var rows = try listRows(&env.database); + defer rows.deinit(); + try testing.expectEqual(id, (try rows.byUrl(url)).id); + try testing.expectEqual(manager.State.ok, (try env.status(id)).state); + + // Asserted last, after the behaviour it explains: the engine wrote no row + // at all, which is why the id above survived and why the restart above had + // files to find. + try testing.expectEqual(@as(u32, 0), pass.summary.sources.total()); + + const decision, _ = try env.evaluate("ads.example.com"); + try testing.expect(decision.blocked); + try testing.expectEqual(matcher.Reason.blocklist_domain, decision.reason); +} + // --------------------------------------------------------------------------- // 11–12: local records, from the database to the wire // --------------------------------------------------------------------------- diff --git a/src/filter/manager.zig b/src/filter/manager.zig index 3df8103..262b21f 100644 --- a/src/filter/manager.zig +++ b/src/filter/manager.zig @@ -1199,6 +1199,14 @@ pub const Manager = struct { if (state != .ok) return true; const last = row.last_updated orelse return true; + // The Pi has no RTC, so a fetch stamped while the clock ran ahead of + // real time (a pre-NTP boot, a restored image) leaves a `last_updated` + // in the future. Plain interval arithmetic would then suspend every + // refresh until real time caught up with the poison stamp, and the + // reconcile engine preserves runtime columns faithfully, so nothing + // else would ever clear it. A stamp from the future is not evidence of + // a recent fetch. + if (last > now) return true; return now - last >= model.updateIntervalSeconds(self.update); } @@ -1674,6 +1682,40 @@ test "acquire before any reload returns null and holds no lock" { manager.lock.unlock(io); } +test "needsRefresh treats a last_updated in the future as due" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var database = try openMigrated(); + defer database.close(); + + var f: fetcher.Fetcher = undefined; + var manager = try testManager(&database, &f); + defer manager.deinit(io); + + // `.ok` is the only state that consults the clock at all; every other one + // is already due, so the arithmetic below would be unreachable without it. + var statuses = [_]SourceStatus{.{ .id = 1, .state = .ok }}; + manager.statuses = &statuses; + defer manager.statuses = &.{}; + + const row = testRow(1, true); + const stamp = row.last_updated.?; + const interval = model.updateIntervalSeconds(manager.update); + + // The ordinary cases still hold: fresh is not due, stale is. + try testing.expect(!manager.needsRefresh(io, row, stamp + 1)); + try testing.expect(manager.needsRefresh(io, row, stamp + interval)); + + // The Pi has no RTC. A fetch stamped while the clock ran ahead of real + // time leaves `now - last` negative, which reads as "fetched moments ago" + // and suspends every refresh until real time catches the poison stamp — + // for a whole day here, and for as long as the clock was wrong in general. + try testing.expect(manager.needsRefresh(io, row, stamp - 1)); + try testing.expect(manager.needsRefresh(io, row, stamp - 86_400)); +} + test "the disk gate skips a scheduled refresh only while writes are critical" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); diff --git a/src/server/phase7_integration_test.zig b/src/server/phase7_integration_test.zig index a6a14e9..60e813d 100644 --- a/src/server/phase7_integration_test.zig +++ b/src/server/phase7_integration_test.zig @@ -965,10 +965,12 @@ test "S7 case 11: the app boots, serves a query and exits zero on shutdown" { shutdown.reset(); defer shutdown.reset(); - var future = try test_io.concurrent(app.run, .{ runner, cli.RunArgs{ .paths = .{ - .data_dir = root, + // `--config` present: the file is authority and the database is converged + // onto it before the listeners bind (milestone-20 ruling 1). + var future = try test_io.concurrent(app.run, .{ runner, cli.RunArgs{ + .paths = .{ .data_dir = root }, .config = config_path, - } } }); + } }); const client_address: net.IpAddress = try .parse("127.0.0.1", 0); const client = try client_address.bind(test_io, .{ .mode = .dgram }); diff --git a/src/storage/config_schema.zig b/src/storage/config_schema.zig index 2ce1177..a30b365 100644 --- a/src/storage/config_schema.zig +++ b/src/storage/config_schema.zig @@ -1,5 +1,5 @@ -//! The `config.db` schema, verbatim from PLAN §11.2, plus the two table orders -//! every other storage session needs. +//! The `config.db` schema, verbatim from PLAN §11.2, plus the table lists every +//! other storage session needs. //! //! The DDL text is data, not code: `migrations.zig` carries it as step 1 and //! never edits it in place. A schema change is a *new* step with new DDL, so @@ -89,8 +89,10 @@ pub const ddl_v1: [:0]const u8 = \\CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); ; -/// Child-before-parent. Used by import's wipe step; correct under -/// `foreign_keys = ON`. +/// Child-before-parent, and correct under `foreign_keys = ON`. The reconcile +/// engine deletes in this order so that every declarative child of a dying +/// parent is removed — and counted — before the parent goes, which keeps the FK +/// cascades a safety net rather than the accountant. /// /// `upstreams`, `local_records`, `forward_zones` and `settings` have no foreign /// keys, so their position is free; `groups` and `blocklist_sources` must come @@ -102,18 +104,28 @@ pub const delete_order = [_][]const u8{ "blocklist_sources", "groups", }; -/// Every table whose emptiness defines "the database has never been configured" -/// (S5.2). `groups` is absent because migration step 1 seeds `(1, 'default')`, -/// so an empty database still holds one group row; `schema_version` is absent -/// for the same reason. -pub const content_tables = [_][]const u8{ - "clients", "client_prefixes", "upstreams", "blocklist_sources", - "group_sources", "rules", "local_records", "forward_zones", - "settings", +/// Every table that holds configuration, in a fixed order. It includes +/// `groups`, because a byte-stability dump has to be able to see a reconcile +/// that renumbered a group. +/// +/// `schema_version` is absent: it is the migration's, not the operator's. +pub const table_names = [_][]const u8{ + "groups", "clients", "client_prefixes", "upstreams", + "blocklist_sources", "group_sources", "rules", "local_records", + "forward_zones", "settings", }; const testing = std.testing; +test "table_names names exactly the tables delete_order does" { + try testing.expectEqual(delete_order.len, table_names.len); + for (delete_order) |name| { + try testing.expect(indexOf(&table_names, name) != null); + } + try testing.expect(indexOf(&table_names, "groups") != null); + try testing.expect(indexOf(&table_names, "schema_version") == null); +} + test "delete_order lists every referrer before the table it references" { // The two parents in the schema. Every child that references them must be // deleted first, or `foreign_keys = ON` turns import's wipe into a @@ -130,15 +142,6 @@ test "delete_order lists every referrer before the table it references" { } } -test "content_tables is delete_order without groups" { - try testing.expectEqual(delete_order.len - 1, content_tables.len); - for (content_tables) |name| { - try testing.expect(indexOf(&delete_order, name) != null); - } - try testing.expect(indexOf(&content_tables, "groups") == null); - try testing.expect(indexOf(&content_tables, "schema_version") == null); -} - fn indexOf(haystack: []const []const u8, needle: []const u8) ?usize { for (haystack, 0..) |item, i| { if (std.mem.eql(u8, item, needle)) return i; diff --git a/src/storage/db.zig b/src/storage/db.zig index 2f4f41b..db628b7 100644 --- a/src/storage/db.zig +++ b/src/storage/db.zig @@ -57,6 +57,7 @@ pub const c = struct { pub extern fn sqlite3_column_bytes(stmt: *c.Stmt, col: c_int) c_int; pub extern fn sqlite3_last_insert_rowid(db: *Sqlite3) i64; pub extern fn sqlite3_changes(db: *Sqlite3) c_int; + pub extern fn sqlite3_total_changes(db: *Sqlite3) c_int; }; /// Result codes, from the vendored `sqlite3.h` (3.53.4). @@ -429,6 +430,15 @@ pub const Db = struct { pub fn changes(self: *Db) i64 { return c.sqlite3_changes(self.handle); } + + /// Every row this connection has inserted, updated or deleted since it was + /// opened. Monotonic, so a caller proves "this call wrote nothing" by + /// reading it either side and comparing — which is stronger than comparing + /// content, because an UPDATE that rewrites identical values still moves + /// this counter. + pub fn totalChanges(self: *Db) i64 { + return c.sqlite3_total_changes(self.handle); + } }; fn openHandle(filename: [:0]const u8, flags: c_int) Error!*c.Sqlite3 { diff --git a/src/storage/migrations.zig b/src/storage/migrations.zig index 3bb7c5f..1d4b91c 100644 --- a/src/storage/migrations.zig +++ b/src/storage/migrations.zig @@ -353,7 +353,7 @@ test "readVersion reads a file database through an immutable open, writing nothi try testing.expectEqual(@as(u32, 1), try readVersion(&database)); } -test "delete_order and content_tables name exactly the tables the schema creates" { +test "delete_order and table_names name exactly the tables the schema creates" { var database = try openMigrated(); defer database.close(); _ = try migrate(&database); @@ -361,7 +361,7 @@ test "delete_order and content_tables name exactly the tables the schema creates for (config_schema.delete_order) |name| { try testing.expect(try tableExists(&database, name)); } - for (config_schema.content_tables) |name| { + for (config_schema.table_names) |name| { try testing.expect(try tableExists(&database, name)); } // delete_order covers every table except `schema_version`. diff --git a/src/storage/repositories/clients_repo.zig b/src/storage/repositories/clients_repo.zig index 322d3c2..56f9922 100644 --- a/src/storage/repositories/clients_repo.zig +++ b/src/storage/repositories/clients_repo.zig @@ -3,13 +3,14 @@ //! `listClients` returns only `hand_edited = 1` rows. A client the server //! materialised from live traffic is runtime state, not configuration, and must //! not appear in an export. `hand_edited` is the only marker of operator intent -//! in this table, so it also decides what `import.isEmpty` counts: a database -//! carrying nothing but materialised rows has never been configured, and a seed -//! file must still be able to fill it. `countClients` counts **all** rows and is -//! a test helper — it deliberately does not answer that question. +//! in this table, so it also decides what the reconcile engine may delete: a +//! declared row the file drops is removed, an observed row is kept whatever the +//! file says, and declaring an observed address promotes that row in place. +//! `countClients` counts **all** rows and is a test helper. //! -//! The import path is list / insert / deleteAll / count, plus the two runtime -//! calls `upsertSeen` and `pruneStale` that `server/clients.zig`'s tracker owns. +//! The configuration path is list / insert / update / delete / count, plus the +//! two runtime calls `upsertSeen` and `pruneStale` that `server/clients.zig`'s +//! tracker owns. //! The REST surface is the third section: it speaks row ids and shows //! every client, materialised ones included. @@ -126,7 +127,7 @@ pub fn deleteAllClients(database: *db.Db) db.Error!void { } /// Counts every row, including the materialised ones `listClients` filters out. -/// Used by tests; `import.isEmpty` counts operator intent instead. +/// Used by tests. pub fn countClients(database: *db.Db) db.Error!i64 { return database.queryInt("SELECT count(*) FROM clients"); } @@ -407,6 +408,67 @@ pub fn replaceClientPrefixes(database: *db.Db, items: []const ClientPrefixInput) try tx.commit(); } +// --------------------------------------------------------------------------- +// reconcile surface (milestone 20) +// --------------------------------------------------------------------------- +// +// `replaceClientPrefixes` above is the REST list resource: one atomic swap of +// the whole table, in a transaction of its own. The reconcile engine cannot use +// it — it runs inside a transaction already, and rewriting every row would +// forfeit the row ids and the zero-writes property the engine exists for — so +// it edits and removes prefixes one at a time instead. + +/// Writes the two columns a prefix row carries besides its identity. +/// +/// `error.NotFound`: no prefix holds `id`. `error.Constraint`: +/// `client_prefixes.prefix` is UNIQUE, or `group_id` names no group. +pub fn updateClientPrefix(database: *db.Db, id: i64, item: ClientPrefixInput) db.Error!void { + var stmt = try database.prepare( + "UPDATE client_prefixes SET prefix = ?2, group_id = ?3, priority = ?4 WHERE id = ?1", + ); + defer stmt.deinit(); + try stmt.bindInt(1, id); + try stmt.bindText(2, item.prefix); + try stmt.bindInt(3, item.group_id); + try stmt.bindInt(4, item.priority); + return crud.execStrict(database, &stmt); +} + +/// `error.NotFound`: no prefix holds `id`. Nothing references +/// `client_prefixes`, so a delete cannot violate a constraint. +pub fn deleteClientPrefix(database: *db.Db, id: i64) db.Error!void { + var stmt = try database.prepare("DELETE FROM client_prefixes WHERE id = ?1"); + defer stmt.deinit(); + try stmt.bindInt(1, id); + return crud.execStrict(database, &stmt); +} + +/// Moves the observed clients of one group to another, and reports how many +/// rows moved. +/// +/// `clients.group_id` references `groups(id)` with no `ON DELETE` action +/// (config_schema.zig:26), so a group that any client still sits in cannot be +/// deleted. When a configuration stops declaring a group, its *declared* +/// clients go with it, but the devices the DNS path materialised into it did +/// not come from the configuration and must not be deleted for a decision that +/// was never about them. They move to the default group, which is also the +/// semantics the operator asked for: they un-declared the group, not the +/// devices. +/// +/// `hand_edited = 1` rows are untouched — those are configuration, and the +/// reconcile engine has already accounted for them. +pub fn reassignObservedClients(database: *db.Db, from_group_id: i64, to_group_id: i64) db.Error!u32 { + var stmt = try database.prepare( + "UPDATE clients SET group_id = ?2 WHERE hand_edited = 0 AND group_id = ?1", + ); + defer stmt.deinit(); + try stmt.bindInt(1, from_group_id); + try stmt.bindInt(2, to_group_id); + try stmt.exec(); + const moved = database.changes(); + return @intCast(@min(moved, std.math.maxInt(u32))); +} + // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- diff --git a/src/storage/repositories/groups_repo.zig b/src/storage/repositories/groups_repo.zig index abd5009..f086e3a 100644 --- a/src/storage/repositories/groups_repo.zig +++ b/src/storage/repositories/groups_repo.zig @@ -254,6 +254,41 @@ pub fn setGroupSources(database: *db.Db, group_id: i64, source_ids: []const i64) try tx.commit(); } +// --------------------------------------------------------------------------- +// reconcile surface (milestone 20) +// --------------------------------------------------------------------------- +// +// `group_sources` has no row id — the pair *is* the identity — so the reconcile +// engine matches on the pair and needs the ids the name-keyed list above +// resolves away. + +pub const GroupSourcePair = struct { group_id: i64, source_id: i64 }; + +/// Every assignment as the pair of ids it is. Nothing to free. +pub fn listGroupSourcePairs(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(GroupSourcePair) { + return crud.listRows( + GroupSourcePair, + database, + gpa, + "SELECT group_id, source_id FROM group_sources ORDER BY group_id, source_id", + readGroupSourcePair, + ); +} + +fn readGroupSourcePair(stmt: *db.Stmt, gpa: Allocator) db.Error!GroupSourcePair { + _ = gpa; + return .{ .group_id = stmt.columnInt(0), .source_id = stmt.columnInt(1) }; +} + +/// Removes one assignment. `error.NotFound`: no row holds the pair. +pub fn deleteGroupSourcePair(database: *db.Db, pair: GroupSourcePair) db.Error!void { + var stmt = try database.prepare("DELETE FROM group_sources WHERE group_id = ?1 AND source_id = ?2"); + defer stmt.deinit(); + try stmt.bindInt(1, pair.group_id); + try stmt.bindInt(2, pair.source_id); + return crud.execStrict(database, &stmt); +} + fn groupExists(database: *db.Db, id: i64) db.Error!bool { var stmt = try database.prepare("SELECT 1 FROM groups WHERE id = ?1"); defer stmt.deinit(); diff --git a/src/storage/repositories/settings_repo.zig b/src/storage/repositories/settings_repo.zig index 8e35baa..ba405e1 100644 --- a/src/storage/repositories/settings_repo.zig +++ b/src/storage/repositories/settings_repo.zig @@ -88,6 +88,18 @@ pub fn putSetting(database: *db.Db, key: []const u8, value: []const u8) db.Error try stmt.exec(); } +/// Removes one key. Silent about a key that is not stored: the reconcile +/// engine's sweep computes the set of keys to drop from a list it has already +/// read, so "no such row" is not a caller error the way it is for a by-id +/// mutation, and `execStrict` would turn a harmless race into a failed +/// transaction. +pub fn deleteSetting(database: *db.Db, key: []const u8) db.Error!void { + var stmt = try database.prepare("DELETE FROM settings WHERE key = ?1"); + defer stmt.deinit(); + try stmt.bindText(1, key); + return stmt.exec(); +} + // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- diff --git a/src/storage/storage_integration_test.zig b/src/storage/storage_integration_test.zig index 27409ef..5e98ec1 100644 --- a/src/storage/storage_integration_test.zig +++ b/src/storage/storage_integration_test.zig @@ -22,7 +22,6 @@ const build_options = @import("build_options"); const Writer = std.Io.Writer; const cli = @import("../cli.zig"); -const bootstrap = @import("../config/bootstrap.zig"); const config_export = @import("../config/export.zig"); const import = @import("../config/import.zig"); const model = @import("../config/model.zig"); @@ -127,7 +126,7 @@ fn openMigrated(f: *const Fixture, name: []const u8) !Data { return .{ .dir = dir, .database = database }; } -fn importInto(f: *const Fixture, data: *Data, file: []const u8, force: bool) !void { +fn importInto(f: *const Fixture, data: *Data, file: []const u8, allow_delete: bool) !void { var diags: validate.Diagnostics = .init(testing.allocator); defer diags.deinit(); return import.importFile( @@ -136,7 +135,7 @@ fn importInto(f: *const Fixture, data: *Data, file: []const u8, force: bool) !vo &data.database, f.tmp.dir, file, - .{ .force = force }, + .{ .allow_delete = allow_delete }, &diags, ); } @@ -655,7 +654,7 @@ test "S7 case 10: a config.db stamped one version ahead is refused and left alon } // --------------------------------------------------------------------------- -// case 11-19: export, import and bootstrap on real files +// case 11-19: export and import on real files // --------------------------------------------------------------------------- test "S7 case 11: an exported file is mode 0600 and starts with the header comment" { @@ -704,7 +703,7 @@ test "S7 case 12: export, import and export again are byte-identical files" { try testing.expectEqualStrings(a, b); } -test "S7 case 13: import refuses a configured database unless --force is given" { +test "S7 case 13: import refuses a diff that deletes rows unless --allow-delete is given" { if (!build_options.integration) return error.SkipZigTest; var f: Fixture = .init(); @@ -716,7 +715,10 @@ test "S7 case 13: import refuses a configured database unless --force is given" defer data.deinit(); try importInto(&f, &data, "first.zon", false); - try testing.expectError(error.DatabaseNotEmpty, importInto(&f, &data, "second.zon", false)); + // The two files name different upstream urls, and a url is the upstream's + // identity: applying the second deletes the first's row, which is what the + // gate exists to stop. + try testing.expectError(error.DestructiveImport, importInto(&f, &data, "second.zon", false)); try testing.expectEqual( @as(i64, 1), try data.database.queryInt( @@ -753,14 +755,15 @@ test "S7 case 14: an invalid import reports every problem and writes nothing" { &data.database, f.tmp.dir, "config.zon", - .{ .force = false }, + .{ .allow_delete = false }, &diags, )) |_| { return error.TestUnexpectedResult; } else |_| {} try testing.expectEqual(@as(usize, 2), diags.problems.items.len); - try testing.expect(try import.isEmpty(&data.database)); + try testing.expectEqual(@as(i64, 0), try data.database.queryInt("SELECT count(*) FROM upstreams")); + try testing.expectEqual(@as(i64, 0), try data.database.queryInt("SELECT count(*) FROM settings")); // Nothing beyond the database and its sidecars was created. var dir = try f.tmp.dir.openDir(io, "data", .{ .iterate = true }); @@ -771,129 +774,6 @@ test "S7 case 14: an invalid import reports every problem and writes nothing" { } } -test "S7 case 15: bootstrap with no configuration file leaves the database empty" { - if (!build_options.integration) return error.SkipZigTest; - - var f: Fixture = .init(); - defer f.deinit(); - - var data = try openMigrated(&f, "data"); - defer data.deinit(); - - var diags: validate.Diagnostics = .init(testing.allocator); - defer diags.deinit(); - - const outcome = try bootstrap.bootstrap( - io, - testing.allocator, - &data.database, - f.tmp.dir, - "config.zon", - &diags, - ); - try testing.expectEqual(bootstrap.Outcome.no_config_file, outcome); - try testing.expect(try import.isEmpty(&data.database)); -} - -test "S7 case 16: bootstrap seeds an empty database from the configuration file" { - if (!build_options.integration) return error.SkipZigTest; - - var f: Fixture = .init(); - defer f.deinit(); - try f.write("config.zon", rich_config); - - var data = try openMigrated(&f, "data"); - defer data.deinit(); - - var diags: validate.Diagnostics = .init(testing.allocator); - defer diags.deinit(); - - const outcome = try bootstrap.bootstrap( - io, - testing.allocator, - &data.database, - f.tmp.dir, - "config.zon", - &diags, - ); - try testing.expectEqual(bootstrap.Outcome.seeded, outcome); - try testing.expectEqual(@as(i64, 2), try data.database.queryInt("SELECT count(*) FROM groups")); - try testing.expectEqual(@as(i64, 2), try data.database.queryInt("SELECT count(*) FROM upstreams")); - try testing.expectEqual( - @as(i64, 1), - try data.database.queryInt("SELECT count(*) FROM clients WHERE ip = 'fd00::1'"), - ); - try testing.expectEqual( - @as(i64, 5353), - try data.database.queryInt("SELECT CAST(value AS INTEGER) FROM settings WHERE key = 'dns.port'"), - ); -} - -test "S7 case 17: bootstrap on a configured database never reads the file" { - if (!build_options.integration) return error.SkipZigTest; - - var f: Fixture = .init(); - defer f.deinit(); - try f.write("seed.zon", minimal_config); - - var data = try openMigrated(&f, "data"); - defer data.deinit(); - try importInto(&f, &data, "seed.zon", false); - - // Unparseable on purpose: the call can only succeed if the file is never - // opened. - try f.write("config.zon", broken_zon); - - var diags: validate.Diagnostics = .init(testing.allocator); - defer diags.deinit(); - - const outcome = try bootstrap.bootstrap( - io, - testing.allocator, - &data.database, - f.tmp.dir, - "config.zon", - &diags, - ); - try testing.expectEqual(bootstrap.Outcome.db_already_configured, outcome); - try testing.expectEqual(@as(usize, 0), diags.problems.items.len); - try testing.expectEqual( - @as(i64, 1), - try data.database.queryInt( - "SELECT count(*) FROM upstreams WHERE url = 'https://dns.example/dns-query'", - ), - ); -} - -test "S7 case 18: bootstrap with an invalid configuration file fails and writes nothing" { - if (!build_options.integration) return error.SkipZigTest; - - var f: Fixture = .init(); - defer f.deinit(); - try f.write("config.zon", two_problem_config); - - var data = try openMigrated(&f, "data"); - defer data.deinit(); - - var diags: validate.Diagnostics = .init(testing.allocator); - defer diags.deinit(); - - if (bootstrap.bootstrap( - io, - testing.allocator, - &data.database, - f.tmp.dir, - "config.zon", - &diags, - )) |outcome| { - std.debug.print("bootstrap unexpectedly returned .{s}\n", .{@tagName(outcome)}); - return error.TestUnexpectedResult; - } else |_| {} - - try testing.expectEqual(@as(usize, 2), diags.problems.items.len); - try testing.expect(try import.isEmpty(&data.database)); -} - test "S7 case 19: writeToFile replaces an existing file and restores mode 0600" { if (!build_options.integration) return error.SkipZigTest; @@ -988,11 +868,13 @@ test "S7 case 21: runCheck passes a seeded database and reports two stored probl try importInto(&f, &data, "config.zon", false); } { - // `applyToDb` rather than an import: the validator would refuse this + // `apply` rather than an import: the validator would refuse this // configuration, and the case needs the problems to reach the database. var data = try openMigrated(&f, "bad"); defer data.deinit(); - try import.applyToDb(io, testing.allocator, &data.database, two_problem_model, 42, .{}); + var diags: validate.Diagnostics = .init(testing.allocator); + defer diags.deinit(); + try import.apply(io, testing.allocator, &data.database, two_problem_model, 42, .{}, &diags); } { @@ -1045,13 +927,16 @@ test "S7 case 22: runCheck probes a real upstream and prints an OK line" { const code = cli.runCheck( captured.runner(), - .{ .paths = .{ .config = config_path }, .config_explicit = true }, + .{ .config = config_path }, true, ); try testing.expectEqual(cli.exit_ok, code); + // Milestone 13 changed the probe line to the redacted `OK upstreams[i]` + // form; this expectation went stale unnoticed because nothing ran -Dlive + // between then and milestone 20. try testing.expect(std.mem.count( u8, captured.out.written(), - "OK https://cloudflare-dns.com/dns-query\n", + "OK upstreams[0] https://cloudflare-dns.com\n", ) == 1); } diff --git a/src/tests.zig b/src/tests.zig index 08bf5c2..9b24004 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -49,7 +49,8 @@ comptime { _ = @import("storage/repositories/settings_repo.zig"); _ = @import("config/export.zig"); _ = @import("config/import.zig"); - _ = @import("config/bootstrap.zig"); + _ = @import("config/loader.zig"); + _ = @import("config/reconcile.zig"); _ = @import("cli.zig"); _ = @import("storage/storage_integration_test.zig"); _ = @import("filter/parsers.zig"); diff --git a/src/web/auth.zig b/src/web/auth.zig index e9785df..59a9969 100644 --- a/src/web/auth.zig +++ b/src/web/auth.zig @@ -58,9 +58,12 @@ pub const cookie_attributes = "HttpOnly; SameSite=Lax; Path=/"; pub const max_password_len = 256; /// Authentication is on exactly when a hash exists (ruling 17). An empty hash -/// is the documented "no password set" state, not a misconfiguration. +/// is the documented "no password set" state, not a misconfiguration; a null +/// one means the settings table holds no hash row at all, which is the same +/// answer. pub fn authEnabled(web: model.Web) bool { - return web.password_hash.len != 0; + const hash = web.password_hash orelse return false; + return hash.len != 0; } pub const Outcome = enum { @@ -453,6 +456,7 @@ fn tokenOf(n: u8) [token_bytes]u8 { test "authEnabled follows the presence of a hash" { try testing.expect(!authEnabled(.{})); + try testing.expect(!authEnabled(.{ .password_hash = null })); try testing.expect(!authEnabled(.{ .password_hash = "" })); try testing.expect(authEnabled(.{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" })); } diff --git a/src/web/handlers/clients.zig b/src/web/handlers/clients.zig index 23057f1..f841f08 100644 --- a/src/web/handlers/clients.zig +++ b/src/web/handlers/clients.zig @@ -24,6 +24,7 @@ const Allocator = std.mem.Allocator; const address = @import("../../platform/address.zig"); const clients_repo = @import("../../storage/repositories/clients_repo.zig"); +const db = @import("../../storage/db.zig"); const http_util = @import("../http_util.zig"); const model = @import("../../config/model.zig"); const mutations = @import("mutations.zig"); @@ -155,7 +156,76 @@ const resource = mutations.Resource(.{ pub const list = resource.list; pub const get = resource.get; -pub const remove = resource.remove; + +/// What file authority found when it went to delete a row. +pub const ObservedDelete = enum { deleted, declared, absent }; + +/// Reads `hand_edited` and acts on it inside one `BEGIN IMMEDIATE`, because the +/// two halves are a single decision. Split across two statements, a concurrent +/// `nxdns import` — which takes the same write lock for its own reconcile — can +/// promote the row between the read and the DELETE, and file authority would +/// delete a client the file had just declared. Holding the write lock across +/// both makes the promotion wait, and it then sees the row already gone or +/// still there, never half of each. +/// +/// A read-only outcome commits an empty transaction, which costs nothing and +/// keeps the one exit path. +fn deleteIfObserved(database: *db.Db, arena: Allocator, id: i64) db.Error!ObservedDelete { + var tx = try db.Tx.begin(database); + errdefer tx.rollback(); + + const row = try clients_repo.getClient(database, arena, id); + const verdict: ObservedDelete = if (row) |found| + (if (found.hand_edited) .declared else .deleted) + else + .absent; + + if (verdict == .deleted) try clients_repo.deleteClient(database, id); + try tx.commit(); + return verdict; +} + +/// DELETE is a `runtime_action` in the route table (milestone-20 ruling 7), so +/// file authority lets it through: an observed row is runtime state the file +/// never declared, and without a way to remove it a mis-identified or departed +/// device would be immortal — the file can promote an IP, never forget one. +/// A row the file *declares* is configuration, and deleting it would contradict +/// the file, so it answers the same 403 the router answers elsewhere. This is +/// the one policy decision that needs a row read, which is why it is here and +/// not a table column. +/// +/// A row that is not there is a 404, exactly as in database mode: file +/// authority must not turn a missing row into a policy verdict. +pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { + const path = switch (state.authority) { + .database => return resource.remove(state, io, request), + .managed_file => |managed| managed, + }; + + const database = mutations.requireConfigDb(state) catch + return mutations.respondFailure(request, mutations.no_config_db, delete_what); + + state.config_lock.lockUncancelable(io); + const outcome = deleteIfObserved(database, request.arena, request.id.?); + state.config_lock.unlock(io); + + switch (outcome catch |err| return mutations.respondFailure( + request, + mutations.dbFailure(err, group_conflict), + delete_what, + )) { + .absent => return mutations.respondFailure(request, .not_found, ""), + .declared => return http_util.respondManagedByFile(request, path), + .deleted => {}, + } + + if (mutations.reload(state, io)) |failure| { + return mutations.respondFailure(request, failure, delete_what); + } + return http_util.respondEmpty(request, .no_content); +} + +const delete_what = "deleting a client"; /// The prefixes are one list resource with no `/{id}` route: the whole set is /// read and replaced (ruling 9), so there is nothing to get or delete by id. @@ -280,6 +350,45 @@ test "deleting a client removes the row and announces the change" { try testing.expectEqual(@as(usize, 1), bench.reloads); } +test "file authority deletes an observed client and refuses a declared one" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + try seedClient(&bench); + try bench.exec( + \\INSERT INTO clients (id, ip, group_id, hand_edited, first_seen, last_seen) + \\VALUES (2, '192.168.1.11', 1, 1, 100, 200); + ); + + // The declared row is configuration; it survives, and nothing is written. + try testing.expectEqual(ObservedDelete.declared, try deleteIfObserved(&bench.database, bench.arena(), 2)); + try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 2")); + + try testing.expectEqual(ObservedDelete.deleted, try deleteIfObserved(&bench.database, bench.arena(), 1)); + try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 1")); + + try testing.expectEqual(ObservedDelete.absent, try deleteIfObserved(&bench.database, bench.arena(), 999)); +} + +test "the observed check and the delete are one transaction" { + var bench: mutations.Bench = undefined; + try bench.init(testing.allocator); + defer bench.deinit(testing.allocator); + try seedClient(&bench); + + // SQLite refuses a `BEGIN IMMEDIATE` inside an open transaction, so a held + // transaction is what proves this takes the write lock rather than reading + // and deleting through two unsynchronised statements — the window a + // concurrent `nxdns import` would promote the row in. Without the + // transaction both statements run and the row is gone. + var tx = try db.Tx.begin(&bench.database); + try testing.expectError(error.Unexpected, deleteIfObserved(&bench.database, bench.arena(), 1)); + tx.rollback(); + + // The row is untouched: the refusal happened before any statement ran. + try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 1")); +} + test "the prefix list is replaced whole" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); diff --git a/src/web/handlers/settings.zig b/src/web/handlers/settings.zig index f004890..c0eb3ef 100644 --- a/src/web/handlers/settings.zig +++ b/src/web/handlers/settings.zig @@ -125,9 +125,15 @@ fn Partial(comptime Section: type, comptime section_name: []const u8) type { /// Enums arrive as the words the database stores, so they are parsed from text /// rather than by tag name (`logging.level` is `error`, whose tag cannot be). +/// +/// An optional model field collapses to its child, because `Partial` wraps +/// every field in one optional of its own and that optional already carries the +/// only meaning a PUT has for absence — "leave it". A double optional would be +/// two ways to say the same thing, and `std.json` cannot parse the outer one. fn FieldType(comptime T: type) type { return switch (@typeInfo(T)) { .@"enum" => []const u8, + .optional => |info| FieldType(info.child), else => T, }; } @@ -321,16 +327,17 @@ pub fn applyPut( // The password never becomes a row: the hash made above is what the merged // configuration — and therefore the settings table — carries. - const previous_hash = cfg.web.password_hash; + const previous_hash = cfg.web.password_hash orelse ""; if (password != null) cfg.web.password_hash = new_hash; - cfg.web.password = ""; + cfg.web.password = null; if (try problem(arena, cfg)) |text| return .{ .fail = .{ .invalid = text } }; // The gpa copy the live holder will own, made before the write so a // committed transaction can never be followed by a failed revocation. - const hash_changed = password != null and !std.mem.eql(u8, previous_hash, cfg.web.password_hash); - const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, cfg.web.password_hash) else null; + const merged_hash = cfg.web.password_hash orelse ""; + const hash_changed = password != null and !std.mem.eql(u8, previous_hash, merged_hash); + const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, merged_hash) else null; writeSettings(arena, database, cfg) catch |err| { if (replacement) |hash| state.gpa.free(hash); @@ -365,6 +372,12 @@ fn writeSettings(arena: Allocator, database: *db.Db, cfg: model.Config) db.Error for (pairs.items) |pair| { try settings_repo.putSetting(database, pair.key, pair.value); } + // `toSettings` stops at `web.password_hash` (ruling 4 of milestone 20: the + // reconcile engine owns that row, because only it can tell "the file said + // nothing" from "the file said empty"). A PUT has no such ambiguity — the + // merged configuration is the whole truth — so this handler writes the row + // itself rather than losing the password change. + try settings_repo.putSetting(database, "web.password_hash", cfg.web.password_hash orelse ""); try tx.commit(); } @@ -455,6 +468,36 @@ pub const hash_stall_control = if (builtin.is_test) struct { // routes // --------------------------------------------------------------------------- +/// Which source governs this process's configuration, and when it last read +/// it (milestone-20 ruling 7). This is how the UI learns that configuration is +/// read-only — declaratively, rather than by probing a route for a 403. +/// +/// It rides `GET /api/settings` because that route needs a session: the +/// managed path is a filesystem path and must never reach the open +/// `/api/version` or `/api/health`. +/// +/// `reconciled_at` means exactly "this process loaded the file at T". A file +/// whose mtime is newer has not been loaded by the running process. It cannot +/// answer "is the file what the server uses" — a stepped clock or a preserved +/// mtime defeats the comparison in either direction, and the database can move +/// under `nxdns import` without either timestamp moving. +const AuthorityView = struct { + mode: []const u8, + path: ?[]const u8, + reconciled_at: ?i64, +}; + +fn authorityView(state: *const server.WebState) AuthorityView { + return switch (state.authority) { + .database => .{ .mode = "database", .path = null, .reconciled_at = state.reconciled_at }, + .managed_file => |path| .{ + .mode = "managed_file", + .path = path, + .reconciled_at = state.reconciled_at, + }, + }; +} + pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const database = mutations.requireConfigDb(state) catch return mutations.respondFailure(request, mutations.no_config_db, "reading the settings"); @@ -470,7 +513,7 @@ pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError! const cfg = loaded catch |err| return mutations.respondFailure(request, .{ .internal = err }, "reading the settings"); - return respondSettings(request, .ok, cfg); + return respondSettings(request, state, .ok, cfg); } pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { @@ -479,14 +522,20 @@ pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError! return switch (try applyPut(state, io, request.arena, parsed.value)) { .fail => |failure| mutations.respondFailure(request, failure, "writing the settings"), - .config => |cfg| respondSettings(request, .ok, cfg), + .config => |cfg| respondSettings(request, state, .ok, cfg), }; } -fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config) HandlerError!void { +fn respondSettings( + request: *Request, + state: *const server.WebState, + status: std.http.Status, + cfg: model.Config, +) HandlerError!void { return http_util.respondJson(request, status, .{ .settings = view(cfg), .restart_required = restart_required_keys, + .authority = authorityView(state), }, &.{}); } @@ -498,8 +547,10 @@ const testing = std.testing; const auth_handlers = @import("auth.zig"); test "the restart-required table lists every settings key and no secret" { - // `model.toSettings` is the other half of the same fact: the keys the - // database stores, minus the hash the API never serializes. + // `model.toSettings` is the other half of the same fact. The two lists are + // now equal rather than off by one: `toSettings` stopped emitting + // `web.password_hash` (milestone 20 ruling 4) and this table never listed + // it, so both exclude the hash and the plaintext. var pairs: std.ArrayList(model.SettingPair) = .empty; defer { model.freeSettings(testing.allocator, pairs.items); @@ -507,7 +558,7 @@ test "the restart-required table lists every settings key and no secret" { } try model.toSettings(.{}, testing.allocator, &pairs); - try testing.expectEqual(pairs.items.len - 1, restart_required_keys.len); + try testing.expectEqual(pairs.items.len, restart_required_keys.len); for (restart_required_keys) |key| { try testing.expect(!std.mem.eql(u8, key, "web.password_hash")); try testing.expect(!std.mem.eql(u8, key, "web.password")); @@ -641,7 +692,7 @@ test "a new password is stored as a hash and ends every session" { patch.web = .{ .password = "correct horse battery staple" }; const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch); - try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash, "$argon2id$")); + try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash.?, "$argon2id$")); try testing.expect(!sessions.validateAt(bench.io(), &cookie, 1_001)); // The plain password is nowhere in the table, and the hash is. @@ -650,10 +701,10 @@ test "a new password is stored as a hash and ends every session" { try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.password'"), ); const stored = try mutations.loadConfig(bench.arena(), &bench.database); - try testing.expect(std.mem.startsWith(u8, stored.web.password_hash, "$argon2id$")); + try testing.expect(std.mem.startsWith(u8, stored.web.password_hash.?, "$argon2id$")); try testing.expectEqual( auth.Outcome.ok, - try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash, "correct horse battery staple"), + try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash.?, "correct horse battery staple"), ); } @@ -713,7 +764,7 @@ test "an empty password is not a password change" { patch.web = .{ .password = "" }; const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch); - try testing.expectEqualStrings("", outcome.config.web.password_hash); + try testing.expectEqualStrings("", outcome.config.web.password_hash orelse ""); } test "reading the settings with no database is unavailable" { diff --git a/src/web/http_util.zig b/src/web/http_util.zig index 8aec9e9..323ea5f 100644 --- a/src/web/http_util.zig +++ b/src/web/http_util.zig @@ -307,23 +307,38 @@ pub fn parseBody(comptime T: type, request: *Request) (BodyError || error{BadJso /// Ruling 8's envelope. `message` is operator-facing text, never a raw internal /// error string for a 500 (PLAN §19: details go to the log, not the wire). +/// +/// Built on the request arena, like `respondJson` below. It used to build into +/// a 512-byte stack buffer and fall back to `text/plain` when the message +/// overflowed it, which made the documented JSON envelope a function of message +/// length — a long managed-file path (milestone-20 ruling 7) was enough to +/// demote it. The envelope is `application/json` at every length now. pub fn respondError( request: *Request, status: http.Status, message: []const u8, ) HandlerError!void { - var buf: [512]u8 = undefined; - var writer: std.Io.Writer = .fixed(&buf); - var stringify: std.json.Stringify = .{ .writer = &writer }; - stringify.beginObject() catch return respondPlain(request, status, message); - stringify.objectField("error") catch return respondPlain(request, status, message); - stringify.write(message) catch return respondPlain(request, status, message); - stringify.endObject() catch return respondPlain(request, status, message); - return respondBytes(request, status, writer.buffered(), content_type_json, &.{}); + var allocating: std.Io.Writer.Allocating = .init(request.arena); + defer allocating.deinit(); + var stringify: std.json.Stringify = .{ .writer = &allocating.writer }; + stringify.beginObject() catch return error.OutOfMemory; + stringify.objectField("error") catch return error.OutOfMemory; + stringify.write(message) catch return error.OutOfMemory; + stringify.endObject() catch return error.OutOfMemory; + return respondBytes(request, status, allocating.written(), content_type_json, &.{}); } -fn respondPlain(request: *Request, status: http.Status, message: []const u8) HandlerError!void { - return respondBytes(request, status, message, content_type_text, &.{}); +/// Milestone-20 ruling 7's rejection: a configuration write under file +/// authority. One function, because the router rejects most of them and the +/// clients handler rejects the one that needs a row read — two wordings would +/// be two contracts. +pub fn respondManagedByFile(request: *Request, path: []const u8) HandlerError!void { + const message = try std.fmt.allocPrint( + request.arena, + "configuration is managed by {s}; edit the file and restart", + .{path}, + ); + return respondError(request, .forbidden, message); } /// Serialises `value` and responds. The document is built in the request arena diff --git a/src/web/openapi.yaml b/src/web/openapi.yaml index 48aafa7..1f024ab 100644 --- a/src/web/openapi.yaml +++ b/src/web/openapi.yaml @@ -29,6 +29,15 @@ info: - Mutations to groups, blocklists, rules, local records, forward zones, clients and client prefixes take effect live. Upstreams and `/api/settings` are restart-required. + - nxdns runs under one of two configuration authorities. Started with + `--config=`, that file is the sole declarative source, and every + operation that writes configuration answers 403 with the same error + envelope, naming the file. Operations that change runtime state — + `/api/pause`, `POST /api/blocklists/update`, `/api/certs/reload`, the + login and the logout — stay live, as does `DELETE /api/clients/{id}` + for a client the file does not declare. `GET /api/settings` reports + the live authority, so a client reads the mode rather than + discovering it from a rejection. servers: - url: / @@ -391,6 +400,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "409": $ref: "#/components/responses/Conflict" "413": @@ -444,6 +455,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": @@ -466,6 +479,8 @@ paths: description: Deleted; applied live. "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": @@ -519,6 +534,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": @@ -575,6 +592,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "409": $ref: "#/components/responses/Conflict" "413": @@ -655,6 +674,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": @@ -674,6 +695,8 @@ paths: description: Deleted; applied live. "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": @@ -728,6 +751,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "409": $ref: "#/components/responses/Conflict" "413": @@ -780,6 +805,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": @@ -799,6 +826,8 @@ paths: description: Deleted; applied live. "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": @@ -853,6 +882,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "409": $ref: "#/components/responses/Conflict" "413": @@ -905,6 +936,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": @@ -924,6 +957,8 @@ paths: description: Deleted; applied live. "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": @@ -978,6 +1013,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "409": $ref: "#/components/responses/Conflict" "413": @@ -1030,6 +1067,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": @@ -1049,6 +1088,8 @@ paths: description: Deleted; applied live. "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": @@ -1133,6 +1174,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": @@ -1222,6 +1265,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "409": $ref: "#/components/responses/Conflict" "413": @@ -1277,6 +1322,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "409": $ref: "#/components/responses/Conflict" "413": @@ -1330,6 +1377,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": @@ -1350,6 +1399,8 @@ paths: description: Deleted; takes effect on restart. "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": @@ -1454,6 +1505,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ManagedByFile" "413": $ref: "#/components/responses/BodyTooLarge" "429": @@ -1526,6 +1579,16 @@ components: application/json: schema: $ref: "#/components/schemas/Error" + ManagedByFile: + description: | + nxdns is running under file authority and this operation writes + configuration. The message names the file. Authentication is checked + first, so an unauthenticated request to a protected route still + answers 401 rather than disclosing that the route exists. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" NotFound: description: No row has this id. content: @@ -2193,7 +2256,7 @@ components: SettingsEnvelope: type: object - required: [settings, restart_required] + required: [settings, restart_required, authority] properties: settings: $ref: "#/components/schemas/Settings" @@ -2203,6 +2266,41 @@ components: description: | Every `section.field` key that needs a restart to take effect — currently all of them. + authority: + $ref: "#/components/schemas/Authority" + + Authority: + type: object + description: | + Which source governs this process's configuration. This is how a + client learns that configuration is read-only; it never has to probe + a write route for a 403. The block rides this authenticated endpoint + because `path` is a filesystem path, and never appears on the open + `/api/version` or `/api/health`. + required: [mode, path, reconciled_at] + properties: + mode: + type: string + enum: [database, managed_file] + description: | + `database` when nxdns runs without `--config`; `managed_file` + when it runs with it, in which case every configuration write + answers 403. + path: + type: string + nullable: true + description: The managed file, or null in `database` mode. + reconciled_at: + type: integer + nullable: true + description: | + When this process loaded the managed file, in epoch seconds, and + null in `database` mode. It means exactly that: a file whose + mtime is newer has not been loaded by the running process. It + cannot answer whether the file matches what the server serves — + a stepped clock or a preserved mtime defeats the comparison + either way, and `nxdns import` can move the database without + moving either timestamp. SettingsPatch: type: object diff --git a/src/web/openapi.zig b/src/web/openapi.zig index 4697351..31e6906 100644 --- a/src/web/openapi.zig +++ b/src/web/openapi.zig @@ -48,6 +48,84 @@ test "every served route appears textually in the document" { } } +// Drift guard for milestone-20 ruling 7: a route classified `config_write` can +// answer 403 under file authority, so its operation must say so — and a route +// that cannot must not claim it. Textual, like the coverage test above: the +// document has no parser here, and the two facts it compares are one line each. +test "every config write documents the file-authority 403, and nothing else does" { + for (router.routes) |route| { + const operation = try operationBlock(route.pattern, route.method); + const documented = std.mem.containsAtLeast(u8, operation, 1, "\n \"403\":\n"); + if (documented != (route.policy == .config_write)) { + std.debug.print( + "{t} {s} is {t} but {s} a 403\n", + .{ route.method, route.pattern, route.policy, if (documented) "documents" else "does not document" }, + ); + return error.TestUnexpectedResult; + } + } +} + +/// The body of one operation: everything under `pattern`'s `method` key. +/// +/// The path block is bounded *before* the method is looked for. Searching the +/// rest of the document instead would let a later path's `delete:` answer for a +/// path that has none, and the guard above would pass on an operation nobody +/// documented. +fn operationBlock(pattern: []const u8, method: std.http.Method) ![]const u8 { + var key_buf: [128]u8 = undefined; + const path_key = try std.fmt.bufPrint(&key_buf, "\n {s}:\n", .{pattern}); + const path_at = std.mem.indexOf(u8, yaml, path_key) orelse return error.PathNotDocumented; + const path_body = blockUnder(yaml[path_at + path_key.len ..], 2); + + var method_buf: [16]u8 = undefined; + const method_key = try std.fmt.bufPrint(&method_buf, " {s}:\n", .{@tagName(method)}); + _ = std.ascii.lowerString(&method_buf, method_key); + const key = method_buf[0..method_key.len]; + + // Anchored at a line start: a `get:` nested deeper inside a description + // contains the four-space key as a substring. + var offset: usize = 0; + while (offset < path_body.len) { + if (std.mem.startsWith(u8, path_body[offset..], key)) { + return blockUnder(path_body[offset + key.len ..], 4); + } + offset = (std.mem.indexOfScalarPos(u8, path_body, offset, '\n') orelse path_body.len) + 1; + } + return error.MethodNotDocumented; +} + +/// The run of lines at the start of `body` indented deeper than `indent` — what +/// belongs to the key that just ended. `body` starts at a line boundary. Blank +/// lines belong to whatever surrounds them and never close a block. +fn blockUnder(body: []const u8, indent: usize) []const u8 { + var offset: usize = 0; + while (offset < body.len) { + const line_end = std.mem.indexOfScalarPos(u8, body, offset, '\n') orelse body.len; + if (line_end != offset) { + const depth = for (body[offset..line_end], 0..) |c, i| { + if (c != ' ') break i; + } else line_end - offset; + if (depth <= indent) return body[0..offset]; + } + offset = line_end + 1; + } + return body; +} + +test "an operation block stops at its own path and its own method" { + // `/api/groups` has no DELETE. An unbounded search answers with the one + // under `/api/groups/{id}`, and the 403 guard then grades the wrong + // operation — silently passing for a route nobody documented. + try testing.expectError(error.MethodNotDocumented, operationBlock("/api/groups", .DELETE)); + + // A block it does have never reaches into its neighbour under the same + // path either. + const list_groups = try operationBlock("/api/groups", .GET); + try testing.expect(std.mem.containsAtLeast(u8, list_groups, 1, "List groups")); + try testing.expect(!std.mem.containsAtLeast(u8, list_groups, 1, "Create a group")); +} + test "the document names the contract's fixed points" { for ([_][]const u8{ "openapi: 3.0.3", diff --git a/src/web/router.zig b/src/web/router.zig index 407c9c7..253b1ca 100644 --- a/src/web/router.zig +++ b/src/web/router.zig @@ -37,12 +37,20 @@ pub const Auth = enum { open, session }; /// monitoring endpoints so a Prometheus scrape can never be throttled. pub const RateLimit = enum { counted, exempt }; +/// What a route does to the configuration, and therefore whether file +/// authority may allow it (milestone-20 ruling 7). `config_write` changes the +/// declarative state the managed file owns; `runtime_action` changes runtime +/// state the file never declares; `read` changes nothing. +pub const Policy = enum { read, config_write, runtime_action }; + pub const RouteInfo = struct { method: http.Method, /// Segments separated by `/`, with at most one `{id}` capture, which must /// be a positive integer row id. pattern: []const u8, auth: Auth, + /// No default: a new route states its class or does not compile. + policy: Policy, handler: HandlerFn, rate_limit: RateLimit = .counted, }; @@ -158,6 +166,17 @@ pub fn dispatch( return http_util.respondError(request, .unauthorized, "authentication required"); } + // Milestone-20 ruling 7, and it runs *after* the auth check on purpose: + // rejecting before authenticating would tell an anonymous caller which + // routes exist. An unauthenticated request to a protected route answers + // 401 in both authority modes. + if (found.route.policy == .config_write) { + switch (state.authority) { + .database => {}, + .managed_file => |path| return http_util.respondManagedByFile(request, path), + } + } + return found.route.handler(state, io, request); } @@ -196,13 +215,14 @@ fn noopHandler( } const test_table = [_]RouteInfo{ - .{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = noopHandler, .rate_limit = .exempt }, - .{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = noopHandler }, - .{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = noopHandler }, - .{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler }, - .{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler }, - .{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler }, - .{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = noopHandler }, + .{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = noopHandler, .rate_limit = .exempt }, + .{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = noopHandler }, + .{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = noopHandler }, + .{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = noopHandler }, + .{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler }, + .{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler }, + .{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .handler = noopHandler }, + .{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .handler = noopHandler }, }; fn matchPath(method: http.Method, path: []const u8) Match { @@ -263,6 +283,34 @@ test "the allow header lists every method the path accepts" { try testing.expectEqualStrings("GET, PUT, DELETE", formatAllow(&test_table, item.segments(), &buf)); } +test "matching carries the class the table declares, per route and not per prefix" { + const cases = [_]struct { method: http.Method, path: []const u8, policy: Policy }{ + .{ .method = .GET, .path = "/api/groups", .policy = .read }, + .{ .method = .GET, .path = "/api/groups/7", .policy = .read }, + .{ .method = .POST, .path = "/api/groups", .policy = .config_write }, + .{ .method = .PUT, .path = "/api/groups/7", .policy = .config_write }, + .{ .method = .DELETE, .path = "/api/groups/7", .policy = .config_write }, + .{ .method = .PUT, .path = "/api/groups/7/sources", .policy = .config_write }, + // Same prefix, different class: the column is per route. + .{ .method = .POST, .path = "/api/pause", .policy = .runtime_action }, + }; + for (cases) |case| { + try testing.expectEqual(case.policy, matchPath(case.method, case.path).found.route.policy); + } +} + +test "the shipped route table classifies /api/blocklists by route, not by prefix" { + var refresh: ?Policy = null; + var create: ?Policy = null; + for (routes) |route| { + if (route.method != .POST) continue; + if (std.mem.eql(u8, route.pattern, "/api/blocklists/update")) refresh = route.policy; + if (std.mem.eql(u8, route.pattern, "/api/blocklists")) create = route.policy; + } + try testing.expectEqual(Policy.runtime_action, refresh.?); + try testing.expectEqual(Policy.config_write, create.?); +} + test "the shipped route table is the one the router matches against" { try testing.expectEqual(routes_table.table.ptr, routes.ptr); try testing.expectEqual(routes_table.table.len, routes.len); diff --git a/src/web/routes.zig b/src/web/routes.zig index b905a64..827ea80 100644 --- a/src/web/routes.zig +++ b/src/web/routes.zig @@ -18,6 +18,17 @@ //! bucket. The static assets are ruling 18's remaining exemption; they are //! not routes — the router sends unmatched non-`/api` paths to //! `WebState.fallback` before any policy check. +//! +//! `policy` is the third such column, and milestone-20 ruling 7's contract: +//! under file authority the file is the sole declarative source, so a +//! `config_write` answers 403 and a `runtime_action` stays live. It has no +//! default value on purpose — a route added without a stated class must not +//! inherit one. Classification is per route, not per prefix: +//! `POST /api/blocklists/update` is a refresh, a `runtime_action`, while its +//! CRUD siblings write configuration. `DELETE /api/clients/{id}` is a +//! `runtime_action` here because deleting an *observed* row discards runtime +//! state the file never declared; the declared case needs a row read and the +//! clients handler answers it. const router = @import("router.zig"); @@ -43,85 +54,85 @@ const version = @import("handlers/version.zig"); pub const table: []const router.RouteInfo = &.{ // Monitoring and contract (ruling 18's open set, ruling 19's exemptions). - .{ .method = .GET, .pattern = "/metrics", .auth = .open, .handler = metrics.handle, .rate_limit = .exempt }, - .{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = health.handle, .rate_limit = .exempt }, - .{ .method = .GET, .pattern = "/api/version", .auth = .open, .handler = version.handle }, - .{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .handler = openapi.handle }, + .{ .method = .GET, .pattern = "/metrics", .auth = .open, .policy = .read, .handler = metrics.handle, .rate_limit = .exempt }, + .{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = health.handle, .rate_limit = .exempt }, + .{ .method = .GET, .pattern = "/api/version", .auth = .open, .policy = .read, .handler = version.handle }, + .{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .policy = .read, .handler = openapi.handle }, // Authentication. - .{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .handler = auth.login }, - .{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .handler = auth.logout }, + .{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .policy = .runtime_action, .handler = auth.login }, + .{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .policy = .runtime_action, .handler = auth.logout }, // Query log, stats, live stream, lookup. - .{ .method = .GET, .pattern = "/api/queries", .auth = .session, .handler = queries.list }, - .{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .handler = live.stream, .rate_limit = .exempt }, - .{ .method = .GET, .pattern = "/api/stats", .auth = .session, .handler = stats.totals }, - .{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .handler = stats.timeseries }, - .{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .handler = lookup.handle }, - .{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .handler = upstream_health.handle }, + .{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .handler = queries.list }, + .{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .handler = live.stream, .rate_limit = .exempt }, + .{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .handler = stats.totals }, + .{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .handler = stats.timeseries }, + .{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .handler = lookup.handle }, + .{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .handler = upstream_health.handle }, // Groups. - .{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = groups.list }, - .{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = groups.create }, - .{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.get }, - .{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.update }, - .{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.remove }, - .{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.getSources }, - .{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.putSources }, + .{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = groups.list }, + .{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = groups.create }, + .{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = groups.get }, + .{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = groups.update }, + .{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = groups.remove }, + .{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .read, .handler = groups.getSources }, + .{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .handler = groups.putSources }, // Blocklist sources. `/api/blocklists/update` is a literal segment; it // cannot collide with `{id}`, which only matches a positive integer. - .{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.list }, - .{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.create }, - .{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .handler = blocklists.refresh }, - .{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.get }, - .{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.update }, - .{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.remove }, + .{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .policy = .read, .handler = blocklists.list }, + .{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .policy = .config_write, .handler = blocklists.create }, + .{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .policy = .runtime_action, .handler = blocklists.refresh }, + .{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .read, .handler = blocklists.get }, + .{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .handler = blocklists.update }, + .{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .handler = blocklists.remove }, // Rules. - .{ .method = .GET, .pattern = "/api/rules", .auth = .session, .handler = rules.list }, - .{ .method = .POST, .pattern = "/api/rules", .auth = .session, .handler = rules.create }, - .{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.get }, - .{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.update }, - .{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.remove }, + .{ .method = .GET, .pattern = "/api/rules", .auth = .session, .policy = .read, .handler = rules.list }, + .{ .method = .POST, .pattern = "/api/rules", .auth = .session, .policy = .config_write, .handler = rules.create }, + .{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .policy = .read, .handler = rules.get }, + .{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .handler = rules.update }, + .{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .handler = rules.remove }, // Local records. - .{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .handler = local.listRecords }, - .{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .handler = local.createRecord }, - .{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.getRecord }, - .{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.updateRecord }, - .{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.removeRecord }, + .{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .policy = .read, .handler = local.listRecords }, + .{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .policy = .config_write, .handler = local.createRecord }, + .{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .read, .handler = local.getRecord }, + .{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .handler = local.updateRecord }, + .{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .handler = local.removeRecord }, // Forward zones. - .{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .handler = local.listZones }, - .{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .handler = local.createZone }, - .{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.getZone }, - .{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.updateZone }, - .{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.removeZone }, + .{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .policy = .read, .handler = local.listZones }, + .{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .policy = .config_write, .handler = local.createZone }, + .{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .read, .handler = local.getZone }, + .{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .handler = local.updateZone }, + .{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .handler = local.removeZone }, // Clients (no POST — rows come from DNS activity or import, ruling 9). - .{ .method = .GET, .pattern = "/api/clients", .auth = .session, .handler = clients.list }, - .{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.get }, - .{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.update }, - .{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.remove }, - .{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.listPrefixes }, - .{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.putPrefixes }, + .{ .method = .GET, .pattern = "/api/clients", .auth = .session, .policy = .read, .handler = clients.list }, + .{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .policy = .read, .handler = clients.get }, + .{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .policy = .config_write, .handler = clients.update }, + .{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .policy = .runtime_action, .handler = clients.remove }, + .{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .policy = .read, .handler = clients.listPrefixes }, + .{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .policy = .config_write, .handler = clients.putPrefixes }, // Upstreams (restart-required resource). - .{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.list }, - .{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.create }, - .{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.get }, - .{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.update }, - .{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.remove }, + .{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .policy = .read, .handler = upstreams.list }, + .{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .policy = .config_write, .handler = upstreams.create }, + .{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .read, .handler = upstreams.get }, + .{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .handler = upstreams.update }, + .{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .handler = upstreams.remove }, // Pause and settings. - .{ .method = .GET, .pattern = "/api/pause", .auth = .session, .handler = pause.get }, - .{ .method = .POST, .pattern = "/api/pause", .auth = .session, .handler = pause.post }, - .{ .method = .GET, .pattern = "/api/settings", .auth = .session, .handler = settings.get }, - .{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .handler = settings.put }, + .{ .method = .GET, .pattern = "/api/pause", .auth = .session, .policy = .read, .handler = pause.get }, + .{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .handler = pause.post }, + .{ .method = .GET, .pattern = "/api/settings", .auth = .session, .policy = .read, .handler = settings.get }, + .{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .policy = .config_write, .handler = settings.put }, // Certificates (milestone-10 ruling 8). - .{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .handler = certs.post }, + .{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .handler = certs.post }, }; // --------------------------------------------------------------------------- @@ -187,6 +198,71 @@ test "the limiter exemptions are the monitoring endpoints and the live stream" { try testing.expectEqual(exempt.len, found); } +test "the config writes are exactly the declarative mutations" { + const writes = [_][]const u8{ + "POST /api/groups", + "PUT /api/groups/{id}", + "DELETE /api/groups/{id}", + "PUT /api/groups/{id}/sources", + "POST /api/blocklists", + "PUT /api/blocklists/{id}", + "DELETE /api/blocklists/{id}", + "POST /api/rules", + "PUT /api/rules/{id}", + "DELETE /api/rules/{id}", + "POST /api/local-records", + "PUT /api/local-records/{id}", + "DELETE /api/local-records/{id}", + "POST /api/forward-zones", + "PUT /api/forward-zones/{id}", + "DELETE /api/forward-zones/{id}", + "PUT /api/clients/{id}", + "PUT /api/client-prefixes", + "POST /api/upstreams", + "PUT /api/upstreams/{id}", + "DELETE /api/upstreams/{id}", + "PUT /api/settings", + }; + try expectClass(.config_write, &writes); +} + +test "the runtime actions are exactly ruling 7's list" { + const actions = [_][]const u8{ + "POST /api/auth/login", + "POST /api/auth/logout", + "POST /api/blocklists/update", + "DELETE /api/clients/{id}", + "POST /api/pause", + "POST /api/certs/reload", + }; + try expectClass(.runtime_action, &actions); +} + +test "every read is a GET and every GET is a read" { + for (table) |route| { + try testing.expectEqual(route.method == .GET, route.policy == .read); + } +} + +/// Asserts that the routes classified `policy` are exactly `expected`, each +/// written `METHOD /pattern`. +fn expectClass(policy: router.Policy, expected: []const []const u8) !void { + var buf: [64]u8 = undefined; + var found: usize = 0; + for (table) |route| { + if (route.policy != policy) continue; + found += 1; + const label = try std.fmt.bufPrint(&buf, "{t} {s}", .{ route.method, route.pattern }); + var listed = false; + for (expected) |name| listed = listed or std.mem.eql(u8, label, name); + if (!listed) { + std.debug.print("{s} is {t}, and the list does not say so\n", .{ label, policy }); + return error.TestUnexpectedResult; + } + } + try testing.expectEqual(expected.len, found); +} + test "item routes capture one id and collection routes capture none" { for (table) |route| { const captures = std.mem.count(u8, route.pattern, "{id}"); diff --git a/src/web/server.zig b/src/web/server.zig index c72e2a2..634065f 100644 --- a/src/web/server.zig +++ b/src/web/server.zig @@ -102,10 +102,31 @@ pub const ReloadFn = *const fn (state: *WebState, io: std.Io) anyerror!void; /// false` means several of them are never opened at all (ruling 6). A handler /// that finds the collaborator it needs missing answers 503, the same way it /// answers a missing snapshot. +/// Which of the two sources governs this process's configuration (milestone-20 +/// ruling 1). Per-process state, never persisted: authority lives in the +/// invocation, and the database carries no record of who wrote it. +/// +/// The `managed_file` path is owned by `serve`'s arena, which outlives every +/// `WebState`, so nothing here copies it. +pub const Authority = union(enum) { + database, + managed_file: []const u8, +}; + pub const WebState = struct { gpa: Allocator, web: model.Web = .{}, + /// Defaults to `.database`: a `WebState` nobody told about a managed file + /// governs nothing declaratively, which is the safe reading — the mutation + /// routes stay live rather than a half-wired server refusing every write. + authority: Authority = .database, + /// When this process loaded the managed file, in epoch seconds. Null in + /// database mode, which never reconciles. It answers exactly "this process + /// loaded the file at T" and nothing more: a file whose mtime is newer has + /// not been loaded by the running process. + reconciled_at: ?i64 = null, + handler: ?*dns_handler.Handler = null, pause: ?*pause_mod.Pause = null, tracker: ?*clients.Tracker = null, diff --git a/src/web/server_integration_test.zig b/src/web/server_integration_test.zig index aeffb14..8a86cf3 100644 --- a/src/web/server_integration_test.zig +++ b/src/web/server_integration_test.zig @@ -90,11 +90,11 @@ fn bodyThenPathHandler( } const test_routes = [_]router.RouteInfo{ - .{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = okHandler, .rate_limit = .exempt }, - .{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = okHandler }, - .{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = echoLengthHandler }, - .{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = bodyThenPathHandler }, - .{ .method = .GET, .pattern = "/api/lookup", .auth = .open, .handler = echoDomainHandler }, + .{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = okHandler, .rate_limit = .exempt }, + .{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = okHandler }, + .{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = echoLengthHandler }, + .{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = bodyThenPathHandler }, + .{ .method = .GET, .pattern = "/api/lookup", .auth = .open, .policy = .read, .handler = echoDomainHandler }, }; fn denyAll(state: *server.WebState, io: std.Io, request: *const http_util.Request) bool { diff --git a/src/web/web_integration_test.zig b/src/web/web_integration_test.zig index 053121d..724b189 100644 --- a/src/web/web_integration_test.zig +++ b/src/web/web_integration_test.zig @@ -264,6 +264,10 @@ const EnvOptions = struct { sse_max_per_ip: u16 = 3, trusted_proxies: []const u8 = "", fallback: ?router.HandlerFn = null, + /// Milestone-20 ruling 7. `.database` is what every pre-existing test + /// wants; the file-authority tests below name a path. + authority: server.Authority = .database, + reconciled_at: ?i64 = null, }; /// Heap-allocated because `state` and the listener hold pointers into it. @@ -372,6 +376,8 @@ const Env = struct { .sse_max_connections_per_ip = options.sse_max_per_ip, .trusted_proxies = options.trusted_proxies, }, + .authority = options.authority, + .reconciled_at = options.reconciled_at, .live_hash = .init(options.password_hash), .pause = &self.pauser, .manager = &self.mgr, @@ -573,6 +579,7 @@ const SettingsView = struct { blocklist_update: struct { enabled: bool, interval_hours: u16 }, }, restart_required: []const []const u8, + authority: struct { mode: []const u8, path: ?[]const u8, reconciled_at: ?i64 }, }; const Contract = struct { @@ -580,6 +587,9 @@ const Contract = struct { /// Must equal a `routes.zig` pattern; the coverage test enforces it. pattern: []const u8, auth: router.Auth, + /// Milestone-20 ruling 7's class, restated here so the coverage test can + /// hold the served table to it. No default, like the route table. + policy: router.Policy, rate_limit: router.RateLimit = .counted, /// The concrete request target the walk sends. target: []const u8, @@ -597,96 +607,96 @@ const Contract = struct { /// create that made the row, and deletes come last for their resource. const contract = [_]Contract{ // Monitoring and contract. - .{ .method = .GET, .pattern = "/metrics", .auth = .open, .rate_limit = .exempt, .target = "/metrics", .status = 200, .kind = .raw, .needle = "nxdns_up 1" }, - .{ .method = .GET, .pattern = "/api/health", .auth = .open, .rate_limit = .exempt, .target = "/api/health", .status = 200, .check = jsonShape(handlers_health.Body) }, - .{ .method = .GET, .pattern = "/api/version", .auth = .open, .target = "/api/version", .status = 200, .check = jsonShape(handlers_version.Body) }, - .{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .target = "/api/openapi.yaml", .status = 200, .kind = .raw, .needle = "openapi: 3.0.3" }, + .{ .method = .GET, .pattern = "/metrics", .auth = .open, .policy = .read, .rate_limit = .exempt, .target = "/metrics", .status = 200, .kind = .raw, .needle = "nxdns_up 1" }, + .{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .rate_limit = .exempt, .target = "/api/health", .status = 200, .check = jsonShape(handlers_health.Body) }, + .{ .method = .GET, .pattern = "/api/version", .auth = .open, .policy = .read, .target = "/api/version", .status = 200, .check = jsonShape(handlers_version.Body) }, + .{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .policy = .read, .target = "/api/openapi.yaml", .status = 200, .kind = .raw, .needle = "openapi: 3.0.3" }, // Authentication (auth is disabled in the walk's environment; the on/off // matrix has its own test). - .{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200, .check = jsonShape(LoginView) }, - .{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .target = "/api/auth/logout", .status = 200, .check = jsonShape(LogoutView) }, + .{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .policy = .runtime_action, .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200, .check = jsonShape(LoginView) }, + .{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .policy = .runtime_action, .target = "/api/auth/logout", .status = 200, .check = jsonShape(LogoutView) }, // Refresh-all before any source row exists: nothing to fetch, 202 anyway. - .{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .target = "/api/blocklists/update", .status = 202, .check = jsonShape(StatusList) }, + .{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .policy = .runtime_action, .target = "/api/blocklists/update", .status = 202, .check = jsonShape(StatusList) }, // Query log, stats, live stream, upstream health. - .{ .method = .GET, .pattern = "/api/queries", .auth = .session, .target = "/api/queries?limit=10", .status = 200, .check = jsonShape(handlers_queries.Page) }, - .{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse }, - .{ .method = .GET, .pattern = "/api/stats", .auth = .session, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) }, - .{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) }, - .{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .target = "/api/upstream/health", .status = 200, .check = jsonShape(handlers_upstream_health.Body) }, + .{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .target = "/api/queries?limit=10", .status = 200, .check = jsonShape(handlers_queries.Page) }, + .{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse }, + .{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) }, + .{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) }, + .{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .target = "/api/upstream/health", .status = 200, .check = jsonShape(handlers_upstream_health.Body) }, // Groups. The migrated schema seeds `default` as id 1; the POST creates // id 2, which the delete at the end of the walk removes. - .{ .method = .GET, .pattern = "/api/groups", .auth = .session, .target = "/api/groups", .status = 200, .check = jsonShape(GroupsList) }, - .{ .method = .POST, .pattern = "/api/groups", .auth = .session, .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201, .check = jsonShape(GroupEcho) }, - .{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .status = 200, .check = jsonShape(groups_repo.GroupRow) }, - .{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200, .check = jsonShape(GroupEcho) }, - .{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .target = "/api/groups/1/sources", .status = 200, .check = jsonShape(SourceIds) }, - .{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .target = "/api/groups/1/sources", .body = "{\"source_ids\":[]}", .status = 200, .check = jsonShape(SourceIds) }, + .{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .target = "/api/groups", .status = 200, .check = jsonShape(GroupsList) }, + .{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201, .check = jsonShape(GroupEcho) }, + .{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .target = "/api/groups/2", .status = 200, .check = jsonShape(groups_repo.GroupRow) }, + .{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200, .check = jsonShape(GroupEcho) }, + .{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .read, .target = "/api/groups/1/sources", .status = 200, .check = jsonShape(SourceIds) }, + .{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .target = "/api/groups/1/sources", .body = "{\"source_ids\":[]}", .status = 200, .check = jsonShape(SourceIds) }, // Blocklist sources. The POST runs after the refresh above, so the created // row's url is never fetched. - .{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .target = "/api/blocklists", .status = 200, .check = jsonShape(SourcesList) }, - .{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .target = "/api/blocklists", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads\"}", .status = 201, .check = jsonShape(SourceEcho) }, - .{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .status = 200, .check = jsonShape(sources_repo.SourceRow) }, - .{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200, .check = jsonShape(SourceEcho) }, - .{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .status = 204, .kind = .none }, + .{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .policy = .read, .target = "/api/blocklists", .status = 200, .check = jsonShape(SourcesList) }, + .{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .policy = .config_write, .target = "/api/blocklists", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads\"}", .status = 201, .check = jsonShape(SourceEcho) }, + .{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .read, .target = "/api/blocklists/1", .status = 200, .check = jsonShape(sources_repo.SourceRow) }, + .{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200, .check = jsonShape(SourceEcho) }, + .{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .target = "/api/blocklists/1", .status = 204, .kind = .none }, // Rules. The lookup below wants the blocking rule still in place, so the // rule's delete follows it. - .{ .method = .GET, .pattern = "/api/rules", .auth = .session, .target = "/api/rules", .status = 200, .check = jsonShape(RulesList) }, - .{ .method = .POST, .pattern = "/api/rules", .auth = .session, .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201, .check = jsonShape(RuleEcho) }, - .{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .status = 200, .check = jsonShape(RuleShape) }, - .{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 200, .check = jsonShape(RuleEcho) }, - .{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .target = "/api/lookup?domain=ads.example", .status = 200, .check = jsonShape(handlers_lookup.Body) }, - .{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .status = 204, .kind = .none }, + .{ .method = .GET, .pattern = "/api/rules", .auth = .session, .policy = .read, .target = "/api/rules", .status = 200, .check = jsonShape(RulesList) }, + .{ .method = .POST, .pattern = "/api/rules", .auth = .session, .policy = .config_write, .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201, .check = jsonShape(RuleEcho) }, + .{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .policy = .read, .target = "/api/rules/1", .status = 200, .check = jsonShape(RuleShape) }, + .{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 200, .check = jsonShape(RuleEcho) }, + .{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .target = "/api/lookup?domain=ads.example", .status = 200, .check = jsonShape(handlers_lookup.Body) }, + .{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .target = "/api/rules/1", .status = 204, .kind = .none }, // Local records. - .{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .target = "/api/local-records", .status = 200, .check = jsonShape(RecordsList) }, - .{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201, .check = jsonShape(RecordShape) }, - .{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .status = 200, .check = jsonShape(RecordShape) }, - .{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200, .check = jsonShape(RecordShape) }, - .{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .status = 204, .kind = .none }, + .{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .policy = .read, .target = "/api/local-records", .status = 200, .check = jsonShape(RecordsList) }, + .{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .policy = .config_write, .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201, .check = jsonShape(RecordShape) }, + .{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .read, .target = "/api/local-records/1", .status = 200, .check = jsonShape(RecordShape) }, + .{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200, .check = jsonShape(RecordShape) }, + .{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .target = "/api/local-records/1", .status = 204, .kind = .none }, // Forward zones. - .{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .target = "/api/forward-zones", .status = 200, .check = jsonShape(ZonesList) }, - .{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201, .check = jsonShape(local_repo.ForwardZoneRow) }, - .{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) }, - .{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) }, - .{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .status = 204, .kind = .none }, + .{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .policy = .read, .target = "/api/forward-zones", .status = 200, .check = jsonShape(ZonesList) }, + .{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .policy = .config_write, .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201, .check = jsonShape(local_repo.ForwardZoneRow) }, + .{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .read, .target = "/api/forward-zones/1", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) }, + .{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) }, + .{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .target = "/api/forward-zones/1", .status = 204, .kind = .none }, // Clients (row id 1 is seeded — clients have no POST, ruling 9). - .{ .method = .GET, .pattern = "/api/clients", .auth = .session, .target = "/api/clients", .status = 200, .check = jsonShape(ClientsList) }, - .{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .status = 200, .check = jsonShape(clients_repo.ClientRow) }, - .{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200, .check = jsonShape(clients_repo.ClientRow) }, - .{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .status = 204, .kind = .none }, - .{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .target = "/api/client-prefixes", .status = 200, .check = jsonShape(PrefixesList) }, - .{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200, .check = jsonShape(PrefixesList) }, + .{ .method = .GET, .pattern = "/api/clients", .auth = .session, .policy = .read, .target = "/api/clients", .status = 200, .check = jsonShape(ClientsList) }, + .{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .policy = .read, .target = "/api/clients/1", .status = 200, .check = jsonShape(clients_repo.ClientRow) }, + .{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .policy = .config_write, .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200, .check = jsonShape(clients_repo.ClientRow) }, + .{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .policy = .runtime_action, .target = "/api/clients/1", .status = 204, .kind = .none }, + .{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .policy = .read, .target = "/api/client-prefixes", .status = 200, .check = jsonShape(PrefixesList) }, + .{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .policy = .config_write, .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200, .check = jsonShape(PrefixesList) }, // Upstreams. Row id 1 is seeded; the POST creates id 2, whose delete // cannot collide with the last-enabled-upstream guard. - .{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .target = "/api/upstreams", .status = 200, .check = jsonShape(UpstreamsList) }, - .{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201, .check = jsonShape(UpstreamEcho) }, - .{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/1", .status = 200, .check = jsonShape(upstreams_repo.UpstreamRow) }, - .{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200, .check = jsonShape(UpstreamEcho) }, - .{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/2", .status = 204, .kind = .none }, + .{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .policy = .read, .target = "/api/upstreams", .status = 200, .check = jsonShape(UpstreamsList) }, + .{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .policy = .config_write, .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201, .check = jsonShape(UpstreamEcho) }, + .{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .read, .target = "/api/upstreams/1", .status = 200, .check = jsonShape(upstreams_repo.UpstreamRow) }, + .{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200, .check = jsonShape(UpstreamEcho) }, + .{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .target = "/api/upstreams/2", .status = 204, .kind = .none }, // Pause and settings. The pause POST leaves filtering running; the // settings PUT is a real change, echoed by the same response shape. - .{ .method = .GET, .pattern = "/api/pause", .auth = .session, .target = "/api/pause", .status = 200, .check = jsonShape(handlers_pause.View) }, - .{ .method = .POST, .pattern = "/api/pause", .auth = .session, .target = "/api/pause", .body = "{\"paused\":false}", .status = 200, .check = jsonShape(handlers_pause.View) }, - .{ .method = .GET, .pattern = "/api/settings", .auth = .session, .target = "/api/settings", .status = 200, .check = jsonShape(SettingsView) }, - .{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200, .check = jsonShape(SettingsView) }, + .{ .method = .GET, .pattern = "/api/pause", .auth = .session, .policy = .read, .target = "/api/pause", .status = 200, .check = jsonShape(handlers_pause.View) }, + .{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .target = "/api/pause", .body = "{\"paused\":false}", .status = 200, .check = jsonShape(handlers_pause.View) }, + .{ .method = .GET, .pattern = "/api/settings", .auth = .session, .policy = .read, .target = "/api/settings", .status = 200, .check = jsonShape(SettingsView) }, + .{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .policy = .config_write, .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200, .check = jsonShape(SettingsView) }, // Certificates. The walk's environment wires no cert store, so both // endpoints report disabled — and the reload still answers 200 (m10 // ruling 8: the outcome is the payload). - .{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .target = "/api/certs/reload", .status = 200, .check = jsonShape(handlers_certs.View) }, + .{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .target = "/api/certs/reload", .status = 200, .check = jsonShape(handlers_certs.View) }, // The walk's last delete returns the groups table to its seeded shape. - .{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .status = 204, .kind = .none }, + .{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .target = "/api/groups/2", .status = 204, .kind = .none }, }; // Drift guard: the contract table covers the served route table exactly — @@ -707,6 +717,7 @@ test "the contract table covers every served route with the served policy" { covered[index] = true; try testing.expectEqual(route.auth, entry.auth); try testing.expectEqual(route.rate_limit, entry.rate_limit); + try testing.expectEqual(route.policy, entry.policy); found = true; break; } @@ -933,6 +944,252 @@ test "W10 auth off: an empty hash leaves every route open" { try bounded(env.io(), default_budget, authOff, .{ env.io(), env }); } +// --------------------------------------------------------------------------- +// file authority (milestone-20 ruling 7) +// --------------------------------------------------------------------------- + +const managed_path = "/etc/nxdns/config.zon"; +const managed_body = "{\"error\":\"configuration is managed by " ++ managed_path ++ + "; edit the file and restart\"}"; + +/// Long enough that the envelope could not be built in the 512-byte stack +/// buffer `respondError` used before this milestone. Nested bind mounts really +/// do produce paths like this, and the old code answered them in `text/plain`. +const long_managed_path = "/mnt/" ++ ("deeply-nested-bind-mount/" ** 24) ++ "config.zon"; + +fn fileModeClasses(io: std.Io, env: *Env) anyerror!void { + var body_buf: [8192]u8 = undefined; + var conn: Conn = undefined; + try conn.connect(io, env.addr); + defer conn.close(io); + + // A read is untouched. + try conn.request("GET", "/api/groups", null, null); + var response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + + // Every class of configuration write answers the one envelope. + const writes = [_]struct { method: []const u8, target: []const u8, body: ?[]const u8 }{ + .{ .method = "POST", .target = "/api/groups", .body = "{\"name\":\"kids\"}" }, + .{ .method = "PUT", .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}" }, + .{ .method = "PUT", .target = "/api/clients/1", .body = "{\"name\":\"x\",\"group_id\":1}" }, + .{ .method = "DELETE", .target = "/api/upstreams/1", .body = null }, + }; + for (writes) |write| { + try conn.request(write.method, write.target, null, write.body); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 403), response.status); + try testing.expectEqualStrings(managed_body, response.body); + try testing.expectEqualStrings("application/json", response.header("content-type").?); + } + + // Rejected before the handler, not after it: the group was never created. + try conn.request("GET", "/api/groups", null, null); + response = try conn.receive(&body_buf); + try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "kids")); + + // Runtime actions stay live. + try conn.request("POST", "/api/pause", null, "{\"paused\":false}"); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + + try conn.request("POST", "/api/blocklists/update", null, null); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 202), response.status); + + try conn.request("POST", "/api/certs/reload", null, null); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); +} + +test "W10 milestone 20: file authority rejects configuration writes and spares the rest" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var env = try Env.create(gpa, .{ .authority = .{ .managed_file = managed_path } }); + defer env.destroy(); + + try bounded(env.io(), default_budget, fileModeClasses, .{ env.io(), env }); +} + +fn fileModeClientDelete(io: std.Io, env: *Env) anyerror!void { + var body_buf: [4096]u8 = undefined; + var conn: Conn = undefined; + try conn.connect(io, env.addr); + defer conn.close(io); + + // The declared row contradicts the file, so it stays. + try conn.request("DELETE", "/api/clients/2", null, null); + var response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 403), response.status); + try testing.expectEqualStrings(managed_body, response.body); + + // The observed row is runtime state the file never declared; without this + // a departed device would be immortal, since the file can only promote an + // address, never forget one. + try conn.request("DELETE", "/api/clients/1", null, null); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 204), response.status); + + // An id no client holds is still a 404, not a policy verdict. + try conn.request("DELETE", "/api/clients/999", null, null); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 404), response.status); +} + +test "W10 milestone 20: file authority deletes an observed client and refuses a declared one" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var env = try Env.create(gpa, .{ .authority = .{ .managed_file = managed_path } }); + defer env.destroy(); + + // Row 1 is seeded observed (`hand_edited = 0`); row 2 is what the file + // declares. + try env.config_db.exec( + \\INSERT INTO clients (id, ip, name, group_id, hand_edited, first_seen, last_seen) + \\VALUES (2, '192.168.1.51', 'nas', 1, 1, 1700000000, 1700000000) + ); + + try bounded(env.io(), default_budget, fileModeClientDelete, .{ env.io(), env }); +} + +fn longPathEnvelope(io: std.Io, env: *Env) anyerror!void { + var body_buf: [8192]u8 = undefined; + var conn: Conn = undefined; + try conn.connect(io, env.addr); + defer conn.close(io); + + try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}"); + const response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 403), response.status); + try testing.expect(response.body.len > 512); + try testing.expectEqualStrings("application/json", response.header("content-type").?); + try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, long_managed_path)); + + // Still the documented envelope, not a truncation and not plain text. + const parsed = try std.json.parseFromSlice( + struct { @"error": []const u8 }, + env.gpa, + response.body, + .{}, + ); + defer parsed.deinit(); +} + +test "W10 milestone 20: an error longer than the old 512-byte buffer stays application/json" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var env = try Env.create(gpa, .{ .authority = .{ .managed_file = long_managed_path } }); + defer env.destroy(); + + try bounded(env.io(), default_budget, longPathEnvelope, .{ env.io(), env }); +} + +fn fileModeUnauthenticated(io: std.Io, env: *Env) anyerror!void { + var body_buf: [4096]u8 = undefined; + var conn: Conn = undefined; + try conn.connect(io, env.addr); + defer conn.close(io); + + // Policy runs after authentication: a caller with no session learns that + // it needs one, never that the route exists and is managed by a file whose + // path the envelope would otherwise disclose. + try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}"); + const response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 401), response.status); + try testing.expectEqualStrings("{\"error\":\"authentication required\"}", response.body); + try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, managed_path)); +} + +test "W10 milestone 20: an unauthenticated configuration write is 401, never 403" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var hash_buf: [256]u8 = undefined; + const hash = try hashTestPassword(gpa, &hash_buf); + + var env = try Env.create(gpa, .{ + .password_hash = hash, + .authority = .{ .managed_file = managed_path }, + }); + defer env.destroy(); + + try bounded(env.io(), default_budget, fileModeUnauthenticated, .{ env.io(), env }); +} + +fn authorityEnvelope(io: std.Io, env: *Env) anyerror!void { + var body_buf: [16384]u8 = undefined; + var conn: Conn = undefined; + try conn.connect(io, env.addr); + defer conn.close(io); + + try conn.request("GET", "/api/settings", null, null); + var response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + + const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{}); + defer parsed.deinit(); + try testing.expectEqualStrings("managed_file", parsed.value.authority.mode); + try testing.expectEqualStrings(managed_path, parsed.value.authority.path.?); + try testing.expectEqual(@as(?i64, 1_700_000_042), parsed.value.authority.reconciled_at); + + // The path is a filesystem path and must not reach the open routes. + for ([_][]const u8{ "/api/version", "/api/health" }) |target| { + try conn.request("GET", target, null, null); + response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, managed_path)); + try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "authority")); + } +} + +test "W10 milestone 20: the settings envelope reports the authority and the open routes do not" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var env = try Env.create(gpa, .{ + .authority = .{ .managed_file = managed_path }, + .reconciled_at = 1_700_000_042, + }); + defer env.destroy(); + + try bounded(env.io(), default_budget, authorityEnvelope, .{ env.io(), env }); +} + +fn databaseAuthorityEnvelope(io: std.Io, env: *Env) anyerror!void { + var body_buf: [16384]u8 = undefined; + var conn: Conn = undefined; + try conn.connect(io, env.addr); + defer conn.close(io); + + try conn.request("GET", "/api/settings", null, null); + const response = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 200), response.status); + + const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{}); + defer parsed.deinit(); + try testing.expectEqualStrings("database", parsed.value.authority.mode); + try testing.expectEqual(@as(?[]const u8, null), parsed.value.authority.path); + try testing.expectEqual(@as(?i64, null), parsed.value.authority.reconciled_at); + + // And nothing is rejected. + try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}"); + const created = try conn.receive(&body_buf); + try testing.expectEqual(@as(u16, 201), created.status); +} + +test "W10 milestone 20: database authority reports null and writes normally" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var env = try Env.create(gpa, .{}); + defer env.destroy(); + + try bounded(env.io(), default_budget, databaseAuthorityEnvelope, .{ env.io(), env }); +} + // --------------------------------------------------------------------------- // oversized cookie headers (ruling 7 of milestone 16) // --------------------------------------------------------------------------- diff --git a/web/.prettierignore b/web/.prettierignore index 993c281..eeaf95d 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -1,3 +1,4 @@ dist/ dist-placeholder/ package-lock.json +dist-sourcemap/ diff --git a/web/src/features/blocklists/BlocklistForm.test.tsx b/web/src/features/blocklists/BlocklistForm.test.tsx index cc17912..c81a135 100644 --- a/web/src/features/blocklists/BlocklistForm.test.tsx +++ b/web/src/features/blocklists/BlocklistForm.test.tsx @@ -10,7 +10,9 @@ test("swallowMutationError drops an ApiError and rethrows anything else", () => test("a rejected submit leaves the typed values in place; a resolved one clears them", async () => { const rejecting = vi.fn(() => Promise.reject(new ApiError(400, "bad url"))); - const { rerender } = render(); + const { rerender } = render( + , + ); const url = screen.getByLabelText("URL") as HTMLInputElement; const name = screen.getByLabelText("Name") as HTMLInputElement; fireEvent.change(url, { target: { value: "https://example.com/list.txt" } }); @@ -22,7 +24,7 @@ test("a rejected submit leaves the typed values in place; a resolved one clears expect(name.value).toBe("Example"); const resolving = vi.fn(() => Promise.resolve()); - rerender(); + rerender(); fireEvent.click(screen.getByRole("button", { name: "Add source" })); await waitFor(() => expect(url.value).toBe("")); expect(name.value).toBe(""); diff --git a/web/src/features/blocklists/BlocklistForm.tsx b/web/src/features/blocklists/BlocklistForm.tsx index b55c4d1..1fb9849 100644 --- a/web/src/features/blocklists/BlocklistForm.tsx +++ b/web/src/features/blocklists/BlocklistForm.tsx @@ -3,6 +3,7 @@ import { ApiError } from "@/lib/api"; import InlineError from "@/lib/InlineError"; import type { Blocklist, BlocklistInput } from "@/lib/types"; import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes"; +import { READ_ONLY_HINT } from "@/features/settings/authority"; /** * Drops the rejection the page already renders inline below the form. Anything @@ -17,12 +18,14 @@ export function swallowMutationError(error: unknown): void { interface BlocklistFormProps { initial?: Blocklist; busy: boolean; + /** File authority: the server answers 403, so the submit stays down. */ + readOnly: boolean; error: Error | null; onSubmit: (input: BlocklistInput) => Promise; onCancel?: () => void; } -export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel }: BlocklistFormProps) { +export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit, onCancel }: BlocklistFormProps) { const [url, setUrl] = useState(initial?.url ?? ""); const [name, setName] = useState(initial?.name ?? ""); const [enabled, setEnabled] = useState(initial?.enabled ?? true); @@ -81,7 +84,12 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel Enabled
- {onCancel !== undefined && ( diff --git a/web/src/features/blocklists/BlocklistsPage.tsx b/web/src/features/blocklists/BlocklistsPage.tsx index 1acc416..868f177 100644 --- a/web/src/features/blocklists/BlocklistsPage.tsx +++ b/web/src/features/blocklists/BlocklistsPage.tsx @@ -22,6 +22,7 @@ import { tdClass, thClass, } from "@/ui/classes"; +import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; export default function BlocklistsPage() { const queryClient = useQueryClient(); @@ -36,6 +37,9 @@ export default function BlocklistsPage() { const sources = useRefreshStatus(); const namesById = new Map(blocklists.map((b) => [b.id, b.name])); + // The refresh below re-fetches the sources the config already declares, so + // it stays live in file mode; every other control here writes config. + const readOnly = useReadOnlyConfig(); async function submitForm(input: BlocklistInput) { if (editing === null) { @@ -122,7 +126,8 @@ export default function BlocklistsPage() { type="checkbox" aria-label={`${b.name} enabled`} checked={b.enabled} - disabled={toggle.isPending} + disabled={toggle.isPending || readOnly} + title={readOnly ? READ_ONLY_HINT : undefined} onChange={() => toggleEnabled(b)} className={focusRing} /> @@ -138,14 +143,17 @@ export default function BlocklistsPage() { -
diff --git a/web/src/features/clients/ClientsPage.tsx b/web/src/features/clients/ClientsPage.tsx index ae7cfdc..d0a901f 100644 --- a/web/src/features/clients/ClientsPage.tsx +++ b/web/src/features/clients/ClientsPage.tsx @@ -7,9 +7,17 @@ import ClientEditDialog from "./ClientEditDialog"; import PrefixesEditor from "./PrefixesEditor"; import InlineError from "@/lib/InlineError"; import { smallButtonClass, tableWrapClass } from "@/ui/classes"; +import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; const cellClass = "px-3 py-2"; +/** + * Deleting an observed row discards runtime state the file never declared, so + * it stays live under file authority; deleting a hand-edited row contradicts + * the file and is the one client DELETE the server answers 403 (ruling 7). + */ +const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart."; + export default function ClientsPage() { const { data: clients } = useSuspenseQuery(clientsQuery()); const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery()); @@ -18,6 +26,7 @@ export default function ClientsPage() { const deleteMutation = useMutation(clientDeleteMutation(queryClient)); const [editing, setEditing] = useState(null); const [confirmingId, setConfirmingId] = useState(null); + const readOnly = useReadOnlyConfig(); return (
@@ -86,14 +95,22 @@ export default function ClientsPage() { diff --git a/web/src/features/clients/PrefixesEditor.tsx b/web/src/features/clients/PrefixesEditor.tsx index e3431d4..627ebad 100644 --- a/web/src/features/clients/PrefixesEditor.tsx +++ b/web/src/features/clients/PrefixesEditor.tsx @@ -6,6 +6,7 @@ import { defaultGroupId } from "@/lib/defaultGroup"; import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor"; import InlineError from "@/lib/InlineError"; import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes"; +import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; interface Props { prefixes: ClientPrefix[]; @@ -19,6 +20,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) { const [validation, setValidation] = useState(null); const dirty = isDirty(state); const fallbackGroupId = defaultGroupId(groups); + const readOnly = useReadOnlyConfig(); const save = () => { const problem = firstProblem(state.rows); @@ -105,7 +107,8 @@ export default function PrefixesEditor({ prefixes, groups }: Props) { @@ -69,6 +77,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[] const [confirming, setConfirming] = useState(false); const [expanded, setExpanded] = useState(false); const isDefault = group.id === DEFAULT_GROUP_ID; + const readOnly = useReadOnlyConfig(); + const lockNote = isDefault ? DEFAULT_GROUP_NOTE : readOnly ? READ_ONLY_HINT : undefined; return (
  • @@ -94,7 +104,12 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[] className={smallInputClass} autoFocus /> - {form?.mode === "create" && ( - + )}
    @@ -181,6 +202,7 @@ export default function RecordsTab() { openForm({ mode: "edit", entity: record })} - className={rowButtonClass} + disabled={readOnly} + title={readOnly ? READ_ONLY_HINT : undefined} + className={`${rowButtonClass} disabled:opacity-50`} > Edit diff --git a/web/src/features/local/ZonesTab.tsx b/web/src/features/local/ZonesTab.tsx index 2078f29..b734a52 100644 --- a/web/src/features/local/ZonesTab.tsx +++ b/web/src/features/local/ZonesTab.tsx @@ -17,16 +17,19 @@ import { rowButtonClass, tableWrapClass, } from "@/ui/classes"; +import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; function ZoneForm({ initial, busy, + readOnly, error, onSubmit, onCancel, }: { initial?: ForwardZone; busy: boolean; + readOnly: boolean; error: unknown; onSubmit: (input: ForwardZoneInput) => void; onCancel: () => void; @@ -70,7 +73,12 @@ function ZoneForm({ />
    -
    {form?.mode === "create" && ( - + )}
    @@ -138,6 +159,7 @@ export default function ZonesTab() { openForm({ mode: "edit", entity: zone })} - className={rowButtonClass} + disabled={readOnly} + title={readOnly ? READ_ONLY_HINT : undefined} + className={`${rowButtonClass} disabled:opacity-50`} > Edit diff --git a/web/src/features/rules/RulesPage.tsx b/web/src/features/rules/RulesPage.tsx index 968ec9d..a59bae7 100644 --- a/web/src/features/rules/RulesPage.tsx +++ b/web/src/features/rules/RulesPage.tsx @@ -6,6 +6,7 @@ import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from import type { Rule, RuleAction, RuleKind } from "@/lib/types"; import { defaultGroupId } from "@/lib/defaultGroup"; import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes"; +import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; export default function RulesPage() { const queryClient = useQueryClient(); @@ -19,6 +20,7 @@ export default function RulesPage() { const [kind, setKind] = useState("exact"); const [action, setAction] = useState("block"); const [groupId, setGroupId] = useState(() => defaultGroupId(groups)); + const readOnly = useReadOnlyConfig(); function onSubmit(event: FormEvent) { event.preventDefault(); @@ -77,7 +79,8 @@ export default function RulesPage() { diff --git a/web/src/features/settings/ReadOnlyConfigBanner.tsx b/web/src/features/settings/ReadOnlyConfigBanner.tsx new file mode 100644 index 0000000..ca62abe --- /dev/null +++ b/web/src/features/settings/ReadOnlyConfigBanner.tsx @@ -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 ( +
    + Configuration is managed by {authority.path}. Edit the file and restart + nxdns to change it; the server rejects edits made here. +
    + ); +} diff --git a/web/src/features/settings/SettingsPage.tsx b/web/src/features/settings/SettingsPage.tsx index 705343d..ee02e0e 100644 --- a/web/src/features/settings/SettingsPage.tsx +++ b/web/src/features/settings/SettingsPage.tsx @@ -5,6 +5,7 @@ import { settingsPutMutation, settingsQuery } from "@/lib/queries"; import { buildSettingsPatch } from "@/lib/settingsDiff"; import type { Settings, SettingsPatch } from "@/lib/types"; import { raiseRestartBanner } from "./restartBanner"; +import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority"; import { focusRing } from "@/ui/classes"; /** True when the patch touches anything besides the write-only `web.password` (ruling 11). */ @@ -242,7 +243,8 @@ export default function SettingsPage() { ); }); const patch = buildSettingsPatch(baseline, edited, password === "" ? undefined : password); - const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending; + const readOnly = useReadOnlyConfig(); + const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending || readOnly; function setField(section: keyof Settings, key: string, value: unknown): void { setEdited((prev) => ({ @@ -273,7 +275,7 @@ export default function SettingsPage() { Changes are validated as a whole; every setting requires a restart to take effect.

    -
    +
    {SECTIONS.map(({ section, title, fields }) => (
    {title} @@ -339,6 +341,7 @@ export default function SettingsPage() { {onCancel !== undefined && ( diff --git a/web/src/features/upstreams/UpstreamsPage.tsx b/web/src/features/upstreams/UpstreamsPage.tsx index 8e43734..b6c4ffe 100644 --- a/web/src/features/upstreams/UpstreamsPage.tsx +++ b/web/src/features/upstreams/UpstreamsPage.tsx @@ -6,6 +6,7 @@ import type { Upstream, UpstreamInput } from "@/lib/types"; import { raiseRestartBanner } from "../settings/restartBanner"; import UpstreamForm from "./UpstreamForm"; import { dangerLinkButtonClass, focusRing, linkButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes"; +import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; export default function UpstreamsPage() { const queryClient = useQueryClient(); @@ -16,6 +17,7 @@ export default function UpstreamsPage() { const save = useMutation(upstreamUpdateMutation(queryClient)); const toggle = useMutation(upstreamUpdateMutation(queryClient)); const remove = useMutation(upstreamDeleteMutation(queryClient)); + const readOnly = useReadOnlyConfig(); async function submitForm(input: UpstreamInput) { if (editing === null) { @@ -84,7 +86,8 @@ export default function UpstreamsPage() { type="checkbox" aria-label={`${u.url} enabled`} checked={u.enabled} - disabled={toggle.isPending} + disabled={toggle.isPending || readOnly} + title={readOnly ? READ_ONLY_HINT : undefined} onChange={() => toggleEnabled(u)} className={focusRing} /> @@ -95,14 +98,17 @@ export default function UpstreamsPage() {