milestone 20: declarative configuration for iac

This commit is contained in:
2026-08-11 23:31:40 +02:00
parent 2f29121e27
commit d76afc147a
74 changed files with 6722 additions and 1949 deletions
+40
View File
@@ -10,6 +10,46 @@ subject rarely does.
## [Unreleased]
### Added
- **Declarative configuration for IaC.** `nxdns run --config=<file>` makes the
file the sole source of configuration: every boot converges the database to
it in one transaction, preserving blocklist downloads, compiled lists and
client history, so an unchanged file costs zero downloads and zero writes.
Bare `nxdns run` keeps the database (and the web UI) in charge, exactly as
before. In file mode the web UI is read-only for configuration and says so;
runtime actions (pause, blocklist refresh, certificate reload) stay live.
`GET /api/settings` reports which authority governs the process.
- `nxdns import` now refuses a file whose application would delete
configuration rows, names the tables and counts, and applies it only with
the new `--allow-delete` flag. Additive and edit-in-place imports need no
flag.
### Changed
- **Breaking: `nxdns run --config <file>` changed meaning.** It used to seed
the database once and then ignore the file; it now makes the file the
authority on every boot, which deletes any configuration the file does not
declare — including edits made through the web UI since the seed. Before
upgrading a unit that carries `--config`: either drop the flag to keep the
database in charge, or adopt file mode with the sequence in the upgrade
guide. Order matters there: export the file with the NEW binary (stopped).
- **Breaking: 0.0.1 exports are refused by this version.** A 0.0.1
`nxdns export` writes both `.password = ""` and the stored
`.password_hash`, and this version refuses a file that carries both. This
bites any old export — an adoption file or a configuration backup fed to
`nxdns import` alike. Fix an existing export by deleting its
`.password = ""` line (keep the `.password_hash` line). Take fresh backups
with the new binary.
- **Breaking: the offline password-change recipe changed.** Setting
`.password = "new"` together with `.password_hash = ""` is now refused
(empty `password_hash` is an explicit "disable authentication", and the two
fields cannot both be present). To change the password in the file: set
`.password` and delete the `.password_hash` line entirely.
- `nxdns import --force` is renamed `--allow-delete`.
- A fresh install no longer seeds from `/etc/nxdns/config.zon` by presence.
Use `nxdns import` once, or run in file mode with `--config`.
## [0.0.1] - 2026-08-09
First release. Everything below is new.
+19 -9
View File
@@ -41,10 +41,9 @@ Do not create `/var/lib/nxdns` or `/var/log/nxdns` by hand. The unit's
`StateDirectory` and `LogsDirectory` settings make systemd create them on first
start, `/var/lib/nxdns` at mode 0700 owned by `nxdns`.
## 2. Write the seed configuration
## 2. Write the configuration
nxdns starts from an empty database only if a configuration file tells it what
to forward to. Write `/etc/nxdns/config.zon`:
nxdns will not start with nothing to forward to. Write `/etc/nxdns/config.zon`:
```zon
.{
@@ -76,16 +75,27 @@ A good file ends with `OK: no problems found`. Exit 2 means `check` found
something to fix and printed every problem it found. The upstream probe sends a
real query, so this needs working DNS on the host.
The file seeds the database once. From the second start onwards it is ignored
and the database is the configuration. The seed's `web.password` is hashed at
import time and the plaintext is never stored, so once you have logged in you
can delete the file:
Load it into the database:
```sh
nxdns import /etc/nxdns/config.zon
```
The packaged unit runs `nxdns run` with no `--config`, so from here the database
is the configuration and nothing reads the file again. `web.password` is hashed
and the plaintext is never stored, so once you have logged in you can delete the
file:
```sh
rm /etc/nxdns/config.zon
```
A kept seed is not a backup. `nxdns export` is.
A kept file is not a backup. `nxdns export` is.
To keep the file as the configuration instead — converged at every start, with
the UI refusing configuration edits — do not delete it, and add a drop-in that
appends `--config=/etc/nxdns/config.zon` to `ExecStart`. See
`docs/how-to/install-with-systemd.md`.
## 3. Start it
@@ -107,7 +117,7 @@ dig @<server-ip> example.com A +short
```
The admin interface is on port 8080 by default; log in with the password from
the seed file. `http://<server-ip>:8080/api/health` reports upstream
the configuration file. `http://<server-ip>:8080/api/health` reports upstream
availability and disk state without a login.
## More
+17 -6
View File
@@ -9,6 +9,8 @@ you what asked for what.
- Blocklist filtering: subscribe to hosts/domain lists, plus your own allow
and block rules with wildcard support (`*.example.com`)
- Two configuration modes: a database the web UI edits, or a ZON file you keep
in git and converge onto at every start
- Per-client policy groups: different filtering for the kids' tablet and
your workstation
- Local DNS records and conditional forwarding for internal zones
@@ -39,7 +41,7 @@ says what that signature does and does not prove.
## Quickstart (docker compose)
Seed a minimal configuration and start the published image. This is what the
Write a minimal configuration and start the published image. This is what the
first release will make possible; it does not work today, because there is no
image in the registry to pull:
@@ -60,11 +62,20 @@ The compose file defaults to `:latest`; pin a version for anything you intend
to keep running. To run it before a release exists, build the image yourself and
name it — `NXDNS_IMAGE=nxdns docker compose up -d` — as
[docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md)
describes. DNS is on port 53, the web UI on <http://localhost:8080>. The config
file seeds the database on first boot only; from then on the database is the
truth and changes go through the UI, the API, or `nxdns export` /
`nxdns import`. Full install instructions, including the systemd path and
the Pi 5 recipe, are in
describes. DNS is on port 53, the web UI on <http://localhost:8080>.
The compose file runs `nxdns run --config=/etc/nxdns/config.zon`, which makes
that file the configuration: every start reconciles the database onto it, and
the UI refuses configuration edits. Edit the file and restart to change
anything. Drop the `command:` line to run bare `nxdns run` instead, where the
database is the configuration and changes go through the UI, the API, or
`nxdns export` / `nxdns import` — the packaged systemd unit does that. Which
mode is live is printed at every start (`authority: database` /
`authority: file (<path>)`); see
[docs/explanation/configuration-model.md](docs/explanation/configuration-model.md).
Full install instructions, including the systemd path and the Pi 5 recipe, are
in
[docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) and
[docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md).
+11 -4
View File
@@ -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
+8
View File
@@ -16,6 +16,11 @@ StateDirectoryMode=0700
LogsDirectory=nxdns
ConfigurationDirectory=nxdns
# ConfigurationDirectory creates /etc/nxdns owned by the service user. nxdns
# never writes there in either authority mode, and in file mode that directory
# holds the source of truth, so deny the write outright rather than rely on it.
ReadOnlyPaths=/etc/nxdns
# Port 53 (and 443/853 when the DoH/DoT listeners are enabled).
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
@@ -44,6 +49,9 @@ SystemCallArchitectures=native
Restart=on-failure
RestartSec=2
# Exit 2 is a configuration fault and 64 is a usage error. Neither clears on a
# retry, so a restart loop only buries the diagnostics already in the journal.
RestartPreventExitStatus=2 64
[Install]
WantedBy=multi-user.target
+1 -1
View File
@@ -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.
+12 -6
View File
@@ -35,7 +35,7 @@ Directories:
| `src/upstream/` | Upstream resolution: shared vocabulary and the `Client` interface (`transport.zig`), DoH client (RFC 8484), DoT client (RFC 7858), per-endpoint health and backoff (`health.zig`), and `pool.zig` — priority-ordered failover that is itself a `transport.Client`, so the handler sees one interface. |
| `src/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,
+183 -146
View File
@@ -1,8 +1,9 @@
# The configuration model
nxdns is configured two ways — a ZON file and a web UI — and only one of them
can be the truth. This page explains which, and why that choice is the one
that leaves the fewest ways to lose an operator's work.
can be the truth at a time. This page explains how that choice is made, what
each mode is for, and why the design leaves the fewest ways to lose an
operator's work.
For the fields themselves see
[reference/configuration.md](../reference/configuration.md); for the commands
@@ -10,146 +11,169 @@ and their exit codes see [reference/cli.md](../reference/cli.md).
## The rule
The database is the truth. The file is a seed.
**Authority is the invocation.**
`config.db` in the data directory holds the running configuration. The ZON
file (default `/etc/nxdns/config.zon`, overridable with `--config`) is read on
`nxdns run` only while the database is still empty: if the file exists and the
database holds no configuration, it is imported. Once the import has put rows
in, the file is not opened again.
```
nxdns run the database is the truth
nxdns run --config /etc/nxdns/config.zon the file is the truth
```
The condition is the state of the database, not a one-shot flag. A `run` whose
seed fails — unreadable file, parse error, failed validation, a constraint
violation inside the import transaction — leaves the database empty, so the
next `run` reads the file again. That is what makes fixing a typo and starting
again work.
That is the entire selection mechanism. There is no mode setting, no default
file path, and nothing recorded in the database about which mode last wrote it.
A `config.zon` that exists but that no invocation names changes nothing at all.
`src/config/bootstrap.zig` is that policy and nothing else — a wrapper over
the same import path `nxdns import` uses. Three outcomes:
Two properties fall out of that, and both were chosen on purpose.
- no file: the database is used as it is;
- file present, database empty: seed it;
- file present, database already configured: skip, without reading the file.
**An operator can read `ExecStart` and know which authority is live.** The
alternative — probe a well-known path, and behave differently depending on
whether a file happens to be there — is ambient magic. It is also the exact
class of rule that produced years of documentation lies in this project: the
old design read the file only while the database was empty, which meant the same
command did two different things depending on state nobody could see from the
command line, and every page that described it eventually described it wrongly.
A file that exists but is unreadable, unparseable or invalid fails the start,
with every problem printed. nxdns never falls back to silent defaults over a
file an operator wrote — a resolver that boots "successfully" with a
configuration nobody chose is the worst outcome available, because it looks
like it worked.
**A path already expresses a two-state choice, so a mode flag beside it would
be redundant and worse.** An earlier draft had `--config-source=db|file`. A mode
flag next to a path flag manufactures combinations that cannot mean anything —
a path with no mode, a mode with no path — and each one then needs a pairing
rule and a usage error to defend it. Presence-of-path has no invalid
combinations, so there is nothing to defend.
## Why the database wins
## Database mode
The alternative designs all lose data.
`nxdns run`. `config.db` holds the configuration; the UI, the API and `nxdns
import` write to it; nothing reads a file. This is the appliance: someone sets
the box up once, and afterwards the household member who wants to unblock one
domain clicks a button.
If the file were the truth, the admin UI could not write. Every change would
be an SSH session and a restart, which defeats the reason the UI exists: the
household member who wants to unblock one domain is not going to edit ZON.
A fresh install in this mode starts from an empty database, which fails
validation on its own terms — there is nowhere to forward a query to — and says
what to do about it:
If both were the truth, they would disagree. The UI writes a rule; the file
still says otherwise; the next restart either silently reverts the rule or
silently ignores the file. Both are silent, and both destroy work someone
intended to keep. There is no merge rule that fixes this, because the system
cannot know which of two conflicting statements is the newer intention.
```
nxdns run failed: NoUsableUpstreams
run `nxdns check` to see the configuration in full
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
```
So the file's authority ends the moment the database has content. Editing
`config.zon` after first boot does nothing — no partial effect, no warning
that some fields took and others did not. That is a blunt rule, and it is the
point: the failure mode is "my edit did nothing", which is visible the first
time you look at the UI, rather than "my edit was applied and then quietly
undone next Tuesday".
## File mode
The emptiness check is a real query over the content tables, not a flag: the
database counts as configured when a content table holds rows, or when the
default group has been altered. What it measures is intent, not activity, and
the client table is where those two come apart. Clients are auto-materialised
when they first send a query — the DNS path writes a row per device it sees —
and such a row records what the network did, not what an operator decided. It
carries `hand_edited = 0`, the emptiness check counts only the `hand_edited = 1`
rows, and `export` omits them. So answering queries never turns an unconfigured
database into a configured one; naming a device does.
`nxdns run --config FILE`. The file is the sole declarative source, and the
database becomes the runtime substrate: every start reads the file, validates
it, converges the database onto it, and serves from there. Configuration writes
through the API are refused with a 403.
Counting traffic here would have been a quiet trap: a server that resolved one
name would have declared itself configured and ignored a seed file placed
afterwards, and the operator would have had no line of output saying why.
This is the mode for a file kept in git and pushed by Ansible. What it buys is
that the deployed file is what is running — not "was imported once", not
"was imported unless someone clicked something since".
The same distinction survives an import. `import` replaces the content in one
transaction, which empties `clients` along with every other table, so every
client row is saved before the wipe. The materialised ones are put back after it
unchanged, and restoring a backup does not make the server forget the devices it
has met.
Three properties make it usable rather than merely correct.
An address the imported file names belongs to the file — the operator's
statement wins over the discovered row — with one carve-out. First-seen and
last-seen are not configuration: they record when a device was heard from, the
configuration model has no field for either, and an import is not a query. So
they follow the address rather than the row. If the database already knew that
address, its two timestamps are carried onto the new row; only an address the
database has never seen takes the import's clock. Without that, re-importing a
backup would stamp every device the operator had bothered to name as though it
had just arrived — and those are exactly the devices whose history is worth
something.
**It fails closed.** A file that is missing, unreadable, unparseable, oversized
or invalid stops the start. nxdns never falls back to the database, because a
fallback turns a deploy typo into a configuration that is silently months old
and looks fine. That failure is exit 2, so `nxdns check --config FILE` is a real
pre-restart gate: validate the pushed file in the handler, and a typo is a
failed deploy at noon rather than a dead resolver at the next power cut.
**It converges rather than replaces.** Reconciling matches rows by identity and
writes only what differs. A source whose URL has not changed keeps its row id,
its checksum, its counters and its compiled blocklist files — so a restart in
file mode downloads nothing, which is the difference between a design that is
tolerable to restart and one that costs three minutes and 100 MB every time.
**An unchanged file writes nothing at all.** Not "writes the same bytes" —
performs zero write statements, and reports it:
```
reconciled '/etc/nxdns/config.zon': no changes
```
That matters beyond elegance. A box whose SD card is full of query log can still
restart in file mode, because a no-op reconcile needs no write-ahead-log
headroom.
**Converged at every boot is not a lock between boots.** Nothing stops `nxdns
import` or a `runtime action` route from moving the database while the server
runs. The contract is that the next start puts it back, and says what it
corrected.
## What the file cannot take away
The file is authoritative over configuration. It is not authoritative over
things it has no vocabulary for, and reconciling has to preserve those or the
mode is unusable.
- **Blocklist download state.** Checksums, fetch timestamps and domain counts
belong to the network, not the operator. They survive on every matched row.
- **Client history.** Devices nxdns saw on the wire are kept whole. Naming one
in the file promotes that row in place — it keeps its first-seen and
last-seen and its row id, and counts as an update rather than a delete and an
insert.
- **Devices whose group is un-declared.** Remove a group from the file and the
observed clients assigned to it move to `default`. The operator un-declared
the group, not the devices.
- **The password, when the file does not mention it.** See
[the password](#the-password).
The one thing identity cannot survive is a change to identity itself. Edit a
source's URL and the engine sees one row gone and one row arrived: new id, fresh
download, and the old compiled files swept. That is consistent — artifacts are
keyed by row id — and it is why the CLI asks for `--allow-delete` when a diff
deletes anything.
## Why the database is the substrate in both modes
Even in file mode the database is where the server reads its effective
configuration from. That is not a leftover; it is what lets one read path serve
both modes, and it is what makes the runtime state above have somewhere to live.
If the file were read directly on every query path there would be no place to
keep a checksum, and no way for the UI to show anything. If both file and
database were authoritative there would be a two-way merge, and a merge cannot
work here: when the UI writes a rule and the file still says otherwise, nothing
in the system knows which of two statements is the newer intention. Every
resolution silently destroys work someone meant to keep.
So the modes are exclusive, and the failure mode of each is loud. In database
mode, editing the file does nothing, which you find out the first time you look
at the UI. In file mode, the UI refuses the edit to your face with a message
naming the file to edit instead.
## The round trip
Losing the file as an editing surface would be a real loss — text is
diffable, reviewable and easy to back up — so the file is kept as a
*rendering* of the database rather than a rival to it. That is what
`export`/`import` are for:
The file stays useful as an editing surface in both modes — text is diffable,
reviewable and easy to back up — because `export` renders the database into the
same shape `import` and file mode read:
```
nxdns export → canonical ZON → edit → nxdns import --force → config.db
nxdns export → canonical ZON → edit → nxdns import → config.db
```
`nxdns export` renders the database as canonical ZON: a fixed two-line
header, every default emitted, deterministic ordering from the model's field
order and the repositories' `ORDER BY` clauses, and no timestamps or hostnames
anywhere. Runtime columns — first-seen, last-seen, per-source counters — are
absent from the configuration model on purpose, so two exports taken from a
live, busy server are identical. The round trip `export → import → export` is
byte-identical, and a test asserts it.
`nxdns export` writes canonical ZON: a fixed two-line header, every default
emitted, deterministic ordering from the model's field order and the
repositories' `ORDER BY` clauses, and no timestamps or hostnames anywhere.
Runtime columns are absent from the configuration model on purpose, so two
exports taken from a live, busy server are identical. `export → import →
export` is byte-identical, and a test asserts it.
Byte-stability is not cosmetic. It is what makes an exported file usable in
version control and what makes a diff of two exports mean something: any
difference is a configuration change, never noise from when the export ran.
`nxdns import` replaces the whole database content in one transaction. Not a
merge, not a patch: delete every content table in foreign-key-safe order, then
insert what the file says — with the one exception described above. Every client
row is lifted out first. The materialised ones are put back rather than
recreated from a file that never held them, and the observed timestamps of an
address the file *does* name are merged onto its new row. A client the file
leaves out is gone, history included: nothing puts a `hand_edited = 1` row back.
A failed import — bad syntax, failed
validation, a constraint violation halfway through — leaves the database exactly
as it was, because everything happens inside a single `BEGIN IMMEDIATE`.
The output is also identical in both modes — nothing marks a file as coming
from a file-mode box. That is deliberate, because it is what makes export the
adoption tool: the file you check is the file you deploy, byte for byte. The
label an operator wants is in the unit file, where they put it.
Without `--force`, import refuses a database that already holds configuration.
That
check runs *inside* the transaction, after the lock is taken, so it cannot be
raced by a concurrent write. The effect is that a plain `import` can never
clobber a configured server by accident, and clobbering it deliberately takes
one visible extra word on the command line. See
[how-to/back-up-and-restore.md](../how-to/back-up-and-restore.md).
## One model, three surfaces
`src/config/model.zig` defines exactly one `Config` type. The ZON parser
produces it, the database reader produces it, the validator consumes it, the
composition root consumes it, and the settings API derives its key list and
its patch struct from its type information rather than mirroring the fields by
hand. Adding a field in one place therefore cannot leave the other surfaces
behind; the alternative — a file schema, a DB schema and an API schema kept in
step by discipline — is the standard way configuration systems rot.
The model has two shapes of field, and the difference is structural rather
than stylistic. Struct-typed fields are scalar sections (`dns`, `cache`,
`web`, …) and live in a single key/value `settings` table as
`section.field` text rows. Slice-typed fields are collections (groups,
upstreams, clients, rules, local records, forward zones, …) and each gets its
own table with foreign keys. `toSettings` and `fromSettings` are the two
halves of the scalar bridge, generated by an `inline for` over the model, so
the key list is a consequence of the type rather than a second list to
maintain.
Reconciling the same file twice produces a byte-identical database — ids,
checksums, `created_at`, the password hash, the whole settings table — including
when the file is written in a non-canonical but equivalent form, such as
`FD00:0:0:0:0:0:0:1` for an address stored as `fd00::1`. Anything that churns
under an unchanged file is a bug in the engine by definition. That single
invariant is what forces most of the design above: matching on canonical forms,
writing only on difference, treating duplicate rule tuples as a multiset, and
verifying a password rather than re-hashing it.
## Unknown keys, and why the asymmetry is deliberate
@@ -157,52 +181,65 @@ An unknown key in the **database** is warned about and ignored. An unknown key
in the **file** is a hard error.
They are different situations. A settings row the running binary does not
recognise is almost always an older binary reading a database written by a
newer one — a downgrade, or a rollback after a bad upgrade. Refusing to start
there would mean a downgrade bricks the config database, and the operator
would have to hand-edit SQLite to recover. Warning and ignoring means the
downgrade works, the unknown setting sits inert, and the upgrade back picks it
up again.
recognise is almost always an older binary reading a database written by a newer
one — a downgrade, or a rollback after a bad upgrade. Refusing to start there
would mean a downgrade bricks the config database, and the operator would have
to hand-edit SQLite to recover. Warning and ignoring means the downgrade works,
the unknown setting sits inert, and the upgrade back picks it up again.
A key in a file, by contrast, is something a human just typed. The likeliest
cause is a typo, and the second likeliest is a field that no longer exists.
Silently ignoring it would mean the setting the operator believes they applied
was never applied — exactly the silent-divergence failure the whole model is
built to avoid. So the ZON parser rejects unknown fields with a line and
column.
built to avoid. So the ZON parser rejects unknown fields with a line and column.
The tolerance has a boundary worth stating plainly: it covers unknown *keys*,
not unparseable *values*. A known key whose stored text does not decode into
its type is an error, not a warning.
not unparseable *values*. A known key whose stored text does not decode into its
type is an error, not a warning.
## The password
`web.password` is a write-only input. It is never a stored value.
`web.password` is a write-only input. It is never a stored value. A non-empty
one is hashed with argon2id (PHC encoding, OWASP argon2id parameters) into
`web.password_hash`, and the plaintext is cleared before anything is written.
There is no settings row that can hold it: the model skips `web.password` in
both directions of the settings bridge, so the plaintext has nowhere to go even
by accident.
At import time, a non-empty `web.password` is hashed with argon2id (PHC
encoding, OWASP argon2id parameters) into `web.password_hash`, and the
plaintext field is cleared before anything is written. There is no settings
row that can hold it: the model explicitly skips `web.password` in both
directions of the settings bridge, so the plaintext has nowhere to go even by
accident. `nxdns export` always writes `.password = ""` and carries the hash
instead — which is also what makes the round trip stable, since an export that
tried to reproduce a plaintext it never had could not be byte-identical.
Both fields are optional, and this is the one deliberate carve-out from
file-as-sole-truth: **a file that mentions neither leaves the stored hash
alone.**
Setting both `password` and `password_hash` in one file is an error rather
than a precedence rule. The two say different things about what the password
is, and picking a winner would mean the operator's other statement was
silently discarded. The file has to say one thing.
The reason is a trap the design walked into once. An export carries the full PHC
string, which is long and ugly, and an operator committing that file to git will
sooner or later delete the line — meaning "keep the current password". If
absence meant "no password", that edit would reconcile an empty hash over the
stored one and open the admin UI to the entire LAN, silently, because
authentication is on exactly when the hash is non-empty. Silence has to mean
keep. Disabling authentication takes the explicit `password_hash = ""`.
The practical shape of a password change is therefore export, set `.password`
to the new value, clear `.password_hash`, import with `--force`. The procedure
is in
[how-to/set-up-admin-authentication.md](../how-to/set-up-admin-authentication.md).
The other end of the same problem is `password = ""`. Hashing the empty string
produces a perfectly valid hash, so authentication would be *on* — while the
login handler refuses every empty password, so it could never be satisfied.
Auth on and unreachable is worse than either alternative, so that file is
refused at validation with a diagnostic naming the remedy.
A plaintext password that has not changed is verified against the stored hash
and kept rather than re-hashed. That is byte-stability, not a saving:
verification recomputes the same argon2id function with the stored salt and
costs exactly what hashing costs. Hashing unconditionally would generate a fresh
salt on every start and break the invariant above.
Export's canonical form is therefore `password = null` beside the stored
`password_hash`. Writing an empty *string* there instead would make every export
carry a present-but-empty password next to a hash — tripping the both-set rule
on re-import, so export's own output would fail export's own contract.
## What is not configuration
Storage paths are process arguments, not configuration fields: `--data-dir`,
`--config`, `--web-dev`. They cannot live in the file, because the file is
found by way of them — a path that told you where to find the thing that told
you the path would be circular. They are also the settings a supervisor
(systemd, Docker) owns rather than the operator's policy about DNS. See
`--config`, `--web-dev`. They cannot live in the file, because the file is found
by way of them — a path that told you where to find the thing that told you the
path would be circular. They are also the settings a supervisor (systemd,
Docker) owns rather than the operator's policy about DNS. See
[reference/files-and-directories.md](../reference/files-and-directories.md).
+106 -58
View File
@@ -1,9 +1,22 @@
# Back up and restore
The configuration database is the only state worth keeping. `nxdns export`
writes it out as a ZON file and `nxdns import` writes one back. The query log is
deliberately not part of a backup: it is expendable history, and if it is
missing it gets recreated empty.
The configuration is the only state worth keeping. `nxdns export` writes it out
as a ZON file and `nxdns import` writes one back. The query log is deliberately
not part of a backup: it is expendable history, and if it is missing it gets
recreated empty.
**Which authority the service runs under decides what the backup *is*.** Check
the start log:
- `authority: database``config.db` holds the configuration. Back it up with
`nxdns export`, and restore with `nxdns import` or by replacing the database
file.
- `authority: file (<path>)` — that file holds the configuration, and it is
already a text file you can keep in git. **The file is the backup.** Restoring
means putting the file back and restarting; the database rebuilds itself from
it. `config.db` is a cache of the file in this mode, not the thing to preserve.
The rest of this page covers database mode unless it says otherwise.
The commands below use the scratch lab from
[enable DoH and DoT](enable-doh-and-dot.md), data directory
@@ -40,13 +53,16 @@ grep password /tmp/nxdns-lab/backup.zon
```
```
.password = "",
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$Gh+zg9xke6BqSVOiouRbqG50+Bs8ZGcXA6oKgs7lrKg$crTNMu5OI8yKBkp31r4+Y1OUQmLiAlH/qvsIxjBQRq4",
.password = null,
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$hOjjnTrTZ6kU8XrwQuJ7ZFC3B5LumaB4hRe7kBbzJ6Q$YsC2rCTDKv96eEwPhs+D6vbDliLogEZph8EkagKSuy8",
```
Treat backups as secrets. `.password` is always exported as `""` — the
Treat backups as secrets. `.password` is always exported as `null` — the
plaintext is never stored anywhere — so the file re-imports without anyone
knowing the password.
knowing the password. `null` and `""` are different statements here: `null`
means the file says nothing about the password, while an empty `password_hash`
would disable authentication. See
[Password and hash](../reference/configuration.md#password-and-hash).
Without `--out` the export goes to stdout, where the file mode is your
redirect's problem:
@@ -57,7 +73,7 @@ nxdns export --data-dir /tmp/nxdns-lab/data | head -10
```
// nxdns configuration
// generated by `nxdns export` the database is the source of truth
// generated by `nxdns export` from the running configuration
.{
.upstream = .{
.attempt_timeout_ms = 2500,
@@ -75,8 +91,8 @@ exports taken minutes apart differ.
## Restore onto a fresh data directory
This is the normal restore: new machine, new disk, empty data directory. No
`--force`, because there is nothing to overwrite.
This is the normal restore: new machine, new disk, empty data directory.
Nothing to overwrite, so nothing to authorise.
```sh
nxdns import /tmp/nxdns-lab/backup.zon --data-dir /tmp/nxdns-lab/data-restored
@@ -92,64 +108,95 @@ before writing to it.
## Restore over an existing database
`import` refuses a database that already holds configuration, so a plain
`import` can never clobber a configured server by accident:
```sh
nxdns import /tmp/nxdns-lab/backup.zon --data-dir /tmp/nxdns-lab/data
```
```
import failed: DatabaseNotEmpty
```
That exits 2. Say `--force` when replacing is what you mean:
```sh
nxdns import /tmp/nxdns-lab/backup.zon --force --data-dir /tmp/nxdns-lab/data
```
```
imported /tmp/nxdns-lab/backup.zon
```
Stop the server first. `import` replaces the whole configuration underneath a
process that has already read it, and a running server will not notice.
What `--force` does to the client list is worth knowing before you restore an
old backup. A client the backup does not name is removed, and its first-seen
and last-seen go with it — restoring a month-old file drops the devices you
named since. Devices the server discovered from traffic are kept, and a device
the backup does name keeps the first-seen and last-seen the database already
held, so a restore does not restamp your whole network as newly arrived.
On a real install that is the systemd unit:
**Stop the server first.** `import` rewrites configuration underneath a process
that read it at startup, and a running server picks up only part of it —
filtering follows the new rows at the next reload, while upstreams, listeners
and settings stay at their boot values until a restart.
```sh
systemctl stop nxdns
nxdns import /var/backups/nxdns-config.zon --force
nxdns import /var/backups/nxdns-config.zon
systemctl start nxdns
```
**Not verified on this host.** Those three lines are the only commands on this
page that were not run: this machine has no installed nxdns systemd unit
(`systemctl status nxdns` answers `Unit nxdns.service could not be found.`) and
`systemctl stop`/`start` need root. The lab equivalent below was run, and it
exercises the same stop-import-start sequence. In the lab the server is a
foreground `nxdns run`, so stopping it is Ctrl-C in its own terminal:
`import` converges the database onto the file. Rows the file still names are
matched and updated in place; rows it no longer names are deleted. That last
part is what a restore of an old backup does to everything added since, so it
takes a flag:
```sh
nxdns export --data-dir /tmp/nxdns-lab/data --out /tmp/nxdns-lab/pre-restore.zon
# Ctrl-C the `nxdns run` terminal, or `kill` its pid from another shell
nxdns import /tmp/nxdns-lab/pre-restore.zon --force --data-dir /tmp/nxdns-lab/data
nxdns run --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon
nxdns import /tmp/nxdns-lab/old-backup.zon --data-dir /tmp/nxdns-lab/data
```
```
wrote /tmp/nxdns-lab/pre-restore.zon
imported /tmp/nxdns-lab/pre-restore.zon
FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it
import failed: DestructiveImport
```
That exits 2 and rolls the transaction back, so nothing is half-applied. The
message names every table that would lose rows, which is usually enough to tell
an intended restore from the wrong file. Say `--allow-delete` when deleting is
what you mean:
```sh
nxdns import /tmp/nxdns-lab/old-backup.zon --allow-delete --data-dir /tmp/nxdns-lab/data
```
```
imported /tmp/nxdns-lab/old-backup.zon
```
A restore that only puts back what is already there needs no flag at all, and
neither does one that only adds rows. The flag is about deletion specifically —
including deletion in disguise: renaming a group, or correcting a typo in an
upstream URL, changes the row's identity, so the engine sees one row gone and
one arrived.
What survives a restore is worth knowing before you take an old backup out of
the drawer. Blocklist download state is kept for every source whose URL the file
still names — checksum, counters, compiled files, and the row id they are keyed
by — so restoring does not cost a re-download. Devices the server discovered
from traffic are kept whole, and one the backup names keeps the first-seen and
last-seen the database already held, so a restore never restamps your network as
newly arrived. What does go is a client the backup does not name and that was
named by hand: that row is declarative, and it is deleted with the rest.
> The three `systemctl` lines are the only commands on this page that were not
> run: this machine has no installed nxdns unit (`systemctl status nxdns`
> answers `Unit nxdns.service could not be found.`) and `systemctl stop`/`start`
> need root. The `nxdns import` between them is the same command the lab blocks
> above run, which were executed here as written.
## Restoring the database file itself
Copying `config.db` back into place works too, and it is the fastest restore on
a machine that still has one. Two rules, and the second one is where restores go
wrong:
1. **Take the copy from a stopped instance**, or use `nxdns export` instead. A
copy taken while nxdns is running catches the main file without the changes
sitting in its write-ahead log.
2. **Delete any stale `config.db-wal` and `config.db-shm` beside the file you
restore.** SQLite silently discards a write-ahead log that does not match the
database it sits next to. It does not warn, and it does not fail — it just
answers from the main file, so a restore quietly loses its own tail.
```sh
systemctl stop nxdns
rm -f /var/lib/nxdns/config.db-wal /var/lib/nxdns/config.db-shm
cp /var/backups/config.db /var/lib/nxdns/config.db
chown nxdns:nxdns /var/lib/nxdns/config.db
chmod 0600 /var/lib/nxdns/config.db
systemctl start nxdns
```
> Not verified on this host: these need root, an installed unit and
> `/var/lib/nxdns`, none of which exist here.
None of this applies in file mode. There `config.db` is derived state — restore
the configuration file and start the service, and the first reconcile rebuilds
the database from it.
## Verify a backup
The round trip is byte-stable: exporting, importing and exporting again gives
@@ -200,5 +247,6 @@ startup and before `export` and `import`; nothing walks them back, and `nxdns
check` does not run them at all. Take an export before installing a new binary —
see [upgrade](upgrade.md).
Every command on this page was executed on this host as written, except the
`systemctl` block marked **Not verified on this host** above.
Every `nxdns` command on this page was executed on this host as written. The
`systemctl`, `cp`, `chown` and `chmod` lines were not: they need root and an
installed unit, and both blocks holding them say so.
+6 -4
View File
@@ -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
+77 -31
View File
@@ -10,16 +10,29 @@ supported and is the last section of this page.
For what each configuration field means, see
[the configuration reference](../reference/configuration.md).
> Verification: the seed-file failure modes, the run and the two checks in
> step 3 were run on the machine that wrote this page, against an image built
> from this checkout rather than pulled from the registry — no release is
> Verification: the failure modes, the run and the two checks in step 3 were run
> on the machine that wrote an earlier revision of this page, against an image
> built from this checkout rather than pulled from the registry — no release is
> published yet, so nothing on this page could be run against a pulled image,
> and step 1 could not be run at all. One command was run in altered form:
> host port 8080 was occupied here, so the run and the verification commands
> in step 3 were executed with the host side of the port mappings moved to
> 25353 and 28088 rather than the 53 and 8080 printed below. The container
> side was unchanged. See the note in step 3. The `chown` to uid 65532 needs
> root and was not run.
> and step 1 could not be run at all. One command was run in altered form: host
> port 8080 was occupied there, so the run and the verification commands in
> step 3 were executed with the host side of the port mappings moved to 25353
> and 28088 rather than the 53 and 8080 printed below. The container side was
> unchanged. See the note in step 3. The `chown` to uid 65532 needs root and was
> not run.
>
> **Not re-run for the file-mode revision.** The compose file now ships
> `command: ["run", "--config=/etc/nxdns/config.zon"]`, and no container was
> started against that command on this host: staging a release image needs
> `zig build dist`, which refuses to run while `web/dist` is stale, and the web
> bundle was being rebuilt by other work in the same tree at the time. What was
> checked instead is `docker compose -f deploy/docker/compose.yaml config`,
> which resolves the file without contacting a registry and prints the `command`
> and the `:ro` bind mount as written, and the same
> `run --config=<file>` invocation driven directly against the locally built
> binary: it printed the reconcile summary, `authority: file (<path>)`, and
> `no changes` on the second start. The log lines quoted in step 3 come from
> that run, with the paths and ports the container uses.
## 1. Pull and verify the image
@@ -65,7 +78,7 @@ does and does not prove.
> `docker buildx imagetools inspect --format` shape was run here against
> `alpine:3.22` on Docker Hub and printed that image's index digest.
## 2. Get the compose file and write the seed configuration
## 2. Get the compose file and write the configuration
Every path on this page is relative to a checkout of the repository, because
that is how it was verified. Running the published image needs no checkout,
@@ -82,7 +95,7 @@ curl -fLO "$BASE/raw/tag/v$VERSION/deploy/docker/compose.yaml"
> Gitea `1.27.0+dev` and returned the file with a 200.
Compose bind-mounts `deploy/docker/etc-nxdns` read-only at `/etc/nxdns`. Create
it and put the seed file in it:
it and put the configuration in it:
```sh
mkdir -p deploy/docker/etc-nxdns
@@ -100,15 +113,25 @@ upstream:
}
```
Without that file the container exits with code 2 on a fresh volume: an empty
database has nothing to forward to. The log is
`no configuration file at '/etc/nxdns/config.zon'; using the database as it is`
followed by `nxdns run failed: NoUsableUpstreams`.
**The compose file ships file mode**, with
`command: ["run", "--config=/etc/nxdns/config.zon"]`. That file is the
configuration: the container reconciles its database onto it at every start, and
the admin interface answers 403 to configuration edits. To change anything, edit
the file and restart the container. It also means a fresh or recreated
`nxdns-data` volume rebuilds itself from the mounted file with no extra step.
The file is therefore required, and its absence is a hard failure rather than a
start with defaults:
```
FAIL /etc/nxdns/config.zon: no such file
nxdns run failed: ManagedConfigUnreadable
run `nxdns check` to see the configuration in full
```
A file that is present but rejected is a different failure with the same exit
code. No `default` group, no enabled upstream, a syntax error — `run` prints the
diagnostic and exits 2 as well. Both were run here against a locally built
image. A seed file whose only group was named `other`:
diagnostic and exits 2 as well. A file whose only group was named `other`:
```
FAIL groups: no group named 'default'; every unknown client is assigned to it
@@ -116,18 +139,31 @@ nxdns run failed: MissingDefaultGroup
run `nxdns check` to see the configuration in full
```
and an empty `/etc/nxdns`:
Under `restart: unless-stopped` any of these is a restart loop — Docker has no
start limit and will retry forever. Read the lines above the failure, which name
the fault. See [Troubleshoot nxdns](troubleshoot.md).
```
info(config_bootstrap): no configuration file at '/etc/nxdns/config.zon'; using the database as it is
nxdns run failed: NoUsableUpstreams
run `nxdns check` to see the configuration in full
### Database mode in Docker instead
Drop the `command:` line from `compose.yaml` and the container runs
`nxdns run`, with the database as the configuration and the file read by nothing.
On a fresh volume that database is empty and the container exits 2 with
`NoUsableUpstreams`, so load it once before bringing the service up:
```sh
docker compose -f deploy/docker/compose.yaml run --rm nxdns import /etc/nxdns/config.zon
```
Under `restart: unless-stopped` either one is a restart loop, and the exit code
alone no longer tells them apart: read the lines above the failure, which either
name the diagnostic in the file or say there was no file at all. See
[Troubleshoot nxdns](troubleshoot.md).
The file is positional; add `--allow-delete` when re-running it against a
populated volume and the diff deletes rows. Without this step, `restart:
unless-stopped` plus exit 2 is a crash loop with no way out.
> Not run in a container on this host, for the reason in the verification note
> at the top: no image could be staged here. The `nxdns import <file>` and
> `nxdns import <file> --allow-delete` commands inside it were run directly
> against the locally built binary — the first applied an additive file and
> exited 0, the second was required after a plain `import` refused a
> row-deleting file with `DestructiveImport` and exited 2.
The container runs as uid 65532, and the mount is read-only, so the container
cannot repair permissions itself. Mode 0644 works and was used here. If the
@@ -140,9 +176,12 @@ chmod 0600 deploy/docker/etc-nxdns/config.zon
```
> Not verified on this host: `chown` to a uid you do not own needs root. What
> was verified is the failure it prevents — a seed file at 0600 owned by
> another uid makes the container log `nxdns run failed: AccessDenied` and
> restart in a loop. See [Troubleshoot nxdns](troubleshoot.md).
> was verified is the failure it prevents — a configuration file at 0600 owned
> by another uid makes the container refuse to start and restart in a loop. In
> file mode an unreadable file is a configuration fault:
> `FAIL /etc/nxdns/config.zon: not readable` followed by
> `nxdns run failed: ManagedConfigUnreadable`, exit 2. See
> [Troubleshoot nxdns](troubleshoot.md).
## 3. Run it
@@ -169,14 +208,21 @@ bind mount — against the directory holding the file, not against your shell,
and it takes the project name `docker` from that directory either way, which is
why the container is `docker-nxdns-1`.
A healthy first start logs the seeding and the bound sockets:
A healthy first start logs the reconcile, the authority and the bound sockets:
```
info(config_bootstrap): seeded the database from '/etc/nxdns/config.zon'
info(migrations): config.db migrated from schema version 0 to 2
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +45 ~0 -0;
settings keys changed: dns.bind_ipv4 dns.bind_ipv6 dns.port web.bind web.port …
web authentication is now enabled
info(nxdns): authority: file (/etc/nxdns/config.zon)
info(nxdns): nxdns <version> serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53; 1 upstream(s); blocklist generation 1
info(web_server): web interface listening on 0.0.0.0:8080
```
Every later start on an unchanged file reports `reconciled
'/etc/nxdns/config.zon': no changes` and writes nothing to the database.
Confirm it answers and that the admin interface is up:
```sh
+182 -28
View File
@@ -154,11 +154,11 @@ in step 5, and step 4 has to write a file into it before then. systemd does not
mind finding the directory already there; it adjusts the mode and ownership to
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 exporteditimport cycle.
If you would rather keep `/etc/nxdns/config.zon` in git and have every restart
converge onto it, do not delete the file — go to
[Run in file mode](#run-in-file-mode) instead, and skip the `rm`.
> Verified on this host, with a scratch `--config` and `--data-dir` in place of
> `/etc/nxdns` and `/var/lib/nxdns`: a seed written under umask 022 came out
> 0644, `nxdns check --config` on it printed `OK: no problems found` with no
> mode warning, and after `nxdns import` of that seed an `nxdns export` wrote
> `.password = ""` next to a populated `.password_hash =
> "$argon2id$v=19$..."`. The `chown`, `chmod` and `rm` lines above are the
> ordinary root-owned-file operations and were not run against a real
> `/etc/nxdns`, which this host does not have.
> `/etc/nxdns` and `/var/lib/nxdns` — those two paths are the only difference
> from the blocks above. A file written under umask 022 came out 0644,
> `nxdns check --config` on it printed `OK: no problems found` with no mode
> warning, `nxdns import` of it printed the migration line and `imported <path>`
> and exited 0, and a following `nxdns export` wrote `.password = null` next to
> a populated `.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…"`. The
> `chown`, `chmod` and `rm` lines are ordinary root-owned-file operations and
> were not run against a real `/etc/nxdns`, which this host does not have.
## 5. Start it
@@ -263,9 +276,16 @@ nxdns writes to stderr and systemd captures that into the journal; logging
needs no further configuration. Port 53 is privileged, and the unit grants
`CAP_NET_BIND_SERVICE` through `AmbientCapabilities`.
The unit does not restart the service after exit 2 or exit 64
(`RestartPreventExitStatus=2 64`). Those are a wrong configuration and a wrong
command line, and neither clears on a retry — restarting every two seconds until
`StartLimitBurst` gives up would only bury the diagnostics that are already in
the journal. `systemctl status nxdns` shows the failed state; fix the cause and
start it again.
If the start fails, read [Troubleshoot nxdns](troubleshoot.md). The two common
first-install failures are a port 53 already held by `systemd-resolved` and a
seed file that does not parse.
configuration file that does not parse.
## 6. Confirm it answers
@@ -276,7 +296,7 @@ dig @<server-ip> example.com A +short
```
The admin interface is on port 8080 by default; log in with the password from
the seed file. `http://<server-ip>:8080/api/health` reports upstream
the configuration file. `http://<server-ip>:8080/api/health` reports upstream
availability and disk state without a login.
> Not verified on this host as written: `<server-ip>` is a placeholder, and a
@@ -287,6 +307,140 @@ availability and disk state without a login.
> port returned 200. Only the address and the port differ from the lines
> above.
## Run in file mode
In file mode `/etc/nxdns/config.zon` is the configuration: every start converges
the database onto it, and the admin interface refuses configuration edits with a
403 naming the file. Use it when you want the file in git and deployed by
Ansible. Stay in database mode when you want the UI to be the way things change.
The packaged unit is flagless on purpose — it is correct as shipped, and a
commented-out alternative `ExecStart` in a unit file is documentation
masquerading as configuration. File mode is a drop-in.
### Adopt file mode on a box that is already running
Run these in order. **Stop first**, and do not skip that: any edit made through
the UI between an export and the restart would be silently reverted by the first
reconcile, and `nxdns check` against a live database refuses to grade it (below).
If you are arriving here from an upgrade, the binary must already be the new
one before you export. An export written by 0.0.1 carries a `.password = ""`
line this binary refuses; see
[the order trap](upgrade.md#the-order-trap-export-with-the-new-binary-not-the-old-one).
```sh
systemctl stop nxdns
nxdns export --out /etc/nxdns/config.zon
nxdns check --config /etc/nxdns/config.zon
```
```
wrote /etc/nxdns/config.zon
checking configuration file /etc/nxdns/config.zon
OK upstreams[0] https://cloudflare-dns.com
OK: no problems found
```
Then add the drop-in and start:
```sh
mkdir -p /etc/systemd/system/nxdns.service.d
cat > /etc/systemd/system/nxdns.service.d/file-mode.conf <<'EOF'
[Service]
ExecStart=
ExecStart=/usr/local/bin/nxdns run --config=/etc/nxdns/config.zon
EOF
systemctl daemon-reload
systemctl start nxdns
```
The empty `ExecStart=` is required. Without it systemd appends a second command
to the list rather than replacing the first, and the unit tries to run nxdns
twice.
The first start after adoption changes nothing, because the file was rendered
from the database it is now governing:
```
reconciled '/etc/nxdns/config.zon': no changes
info(nxdns): authority: file (/etc/nxdns/config.zon)
info(nxdns): nxdns <version> serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53; 1 upstream(s); blocklist generation 1
```
`authority: file` is the line that confirms the drop-in took. Blocklists,
compiled snapshots and client history all survive, and every later start on an
unchanged file writes nothing either.
The file now carries `web.password_hash`, so restrict it the same way step 4
does — `chown root:nxdns`, `chmod 0640`. The unit's `ReadOnlyPaths=/etc/nxdns`
denies the service write access to that directory, so the process that reads the
file cannot modify it.
### Change the configuration from now on
Edit the file, validate it, restart:
```sh
$EDITOR /etc/nxdns/config.zon
nxdns check --config /etc/nxdns/config.zon
systemctl restart nxdns
```
Make `nxdns check --config` the precondition of any Ansible handler that
restarts nxdns. A file-mode start reads the file on **every** boot, so a bad
push that skips its handler does not fail at deploy time — it detonates at the
next power cut. Validating before restarting turns that into a failed deploy at
noon.
The restart prints what it changed:
```
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +0 ~2 -0;
settings keys changed: dns.port web.port
```
### Leave file mode
Remove the drop-in and restart. The database already holds the last reconciled
state, so nothing else is needed and the server comes back serving the same
configuration:
```sh
rm /etc/systemd/system/nxdns.service.d/file-mode.conf
systemctl daemon-reload
systemctl restart nxdns
```
```
info(nxdns): authority: database
```
> Verified on this host end to end, against a scratch `--data-dir` and a scratch
> configuration path instead of `/var/lib/nxdns` and `/etc/nxdns`, on
> unprivileged ports — this machine has neither of those directories, no root,
> and no installed unit. Every `nxdns` line above was run and produced the output
> shown, with only those paths and the port numbers in the `serving on` line
> differing.
>
> The run: a database-mode instance was started, a blocklist source was added
> through the API to make it UI-configured, then `nxdns check` against the live
> database printed the uncheckpointed-log FAIL and exited 2 (which is why this
> section stops the service first). After the stop, `nxdns export --out` wrote
> the file, `nxdns check --config` on it exited 0 with `OK: no problems found`,
> and the first file-mode start printed `reconciled '<path>': no changes` and
> `authority: file (<path>)`. A second file-mode start printed `no changes`
> again and loaded the 3096006-byte compiled blocklist from disk with no
> download. Dropping the flag printed `authority: database` and served the same
> configuration.
>
> The `systemctl`, `mkdir`, `cat > …/file-mode.conf` and `rm` lines need root and
> an installed unit and were **not** run. What was checked instead:
> `systemd-analyze verify` on `deploy/systemd/nxdns.service` with those exact two
> `ExecStart` lines appended, which reported only the usual
> `Command /usr/local/bin/nxdns is not executable` for the absent binary and
> nothing about the override.
## Raspberry Pi 5
The Pi 5 is aarch64. Nothing about the procedure changes except which tarball
+86 -21
View File
@@ -11,7 +11,7 @@ data directory is `/var/lib/nxdns` and the web port is 8080.
## 1. Set the password
Put it in the seed configuration file, under `web`:
Put it in the configuration file, under `web`:
```zon
.{
@@ -21,17 +21,60 @@ Put it in the seed configuration file, under `web`:
}
```
At import time the plaintext is hashed with argon2id into `web.password_hash`
and discarded. It becomes no database row and appears in no log line. Setting
both `password` and `password_hash` in one file is refused:
The plaintext is hashed with argon2id into `web.password_hash` and discarded. It
becomes no database row and appears in no log line. Setting both `password` and
`password_hash` in one file is refused:
```
web.password: password and password_hash are both set; ambiguity in a security setting is refused
import failed: PasswordAndHashBothSet
```
The seed file is read only while the database is empty. On a server that
already has a database, use step 4 or step 5 instead.
Applying that file — with `nxdns import`, or with a `nxdns run --config` start —
announces the change:
```
web authentication is now enabled
```
### Absent, empty, and set are three different things
The two fields are optional, and the difference between leaving one out and
setting it to `""` is the difference between keeping your password and removing
it:
| The file says | Effect on the stored password |
| --- | --- |
| Neither field | Nothing. It stays exactly as it was. |
| `.password = "…"` | Installs that password. Unchanged plaintext keeps the existing hash rather than re-hashing it. |
| `.password = ""` | Refused. |
| `.password_hash = "$argon2id$…"` | Installs that hash, for example from an export. |
| `.password_hash = ""` | **Removes the password.** Authentication is off. |
Absence has to mean "keep", because the alternative is a foot-gun with a live
round in it. An export carries the full PHC string, which is long and ugly, and
sooner or later someone trims that line out of a file before committing it —
meaning "leave the password alone". If absence meant "no password", that edit
would open the admin interface to the whole LAN without a word.
So removing the password takes the explicit empty string:
```
web authentication is now disabled
```
And an empty plaintext is refused outright, because hashing the empty string
would switch authentication *on* while making every login impossible — the login
handler rejects empty passwords:
```
FAIL web.password: password is set to the empty string; omit the field to keep the stored password, or set password_hash = "" to disable authentication
```
Which of steps 4 and 5 applies to your server depends on its authority. Under
`nxdns run --config FILE` the file is the password: edit it and restart, and the
API refuses the change with a 403. Under bare `nxdns run` the database holds it,
and step 4 or step 5 is how it moves.
## 2. Log in
@@ -63,7 +106,7 @@ The session token comes back in a `Set-Cookie` header, not in the body. In the
jar it looks like this (value redacted here):
```
#HttpOnly_127.0.0.1 FALSE / FALSE 1785770178 nxdns_session <redacted>
#HttpOnly_127.0.0.1 FALSE / FALSE 1786559938 nxdns_session <redacted>
```
The cookie is named `nxdns_session` and carries `HttpOnly; SameSite=Lax;
@@ -123,6 +166,9 @@ already is.
## 4. Change the password on a running server
This is a database-mode procedure. In file mode `PUT /api/settings` answers 403
naming the file; edit `web.password` there and restart instead.
Send the new one to `PUT /api/settings` as `web.password`. The response is the
full settings document; `password` is write-only and `password_hash` is neither
readable nor directly writable, so neither value comes back.
@@ -161,7 +207,7 @@ Log back in with the new password. That is the whole rotation.
If you have lost the password, the admin interface cannot help — go through the
database instead. Export, edit, import. `nxdns export` always writes
`.password = ""` and carries the hash, so an exported file re-imports without
`.password = null` and carries the hash, so an exported file re-imports without
anyone knowing the password. To install a new one, put it in `.password` and
clear `.password_hash`:
@@ -169,11 +215,22 @@ clear `.password_hash`:
nxdns export --data-dir /tmp/nxdns-lab/data --out /tmp/nxdns-lab/rekeyed.zon
```
Edit the `web` section of `/tmp/nxdns-lab/rekeyed.zon` so it reads:
Edit the `web` section of `/tmp/nxdns-lab/rekeyed.zon`: set `.password` to the
new value and **delete the `.password_hash` line entirely**, so the `web` block
carries one password field and not two:
```zon
.password = "offline-password",
.password_hash = "",
```
Deleting the line is the part to get right. Setting `.password_hash = ""`
alongside a plaintext password does not clear the way for it — an empty string
is a present value meaning "no password", so the file then states two
contradictory things and is refused:
```
FAIL web.password: password and password_hash are both set; ambiguity in a security setting is refused
import failed: PasswordAndHashBothSet
```
Stop the server before importing. `import` rewrites the stored hash underneath a
@@ -184,14 +241,17 @@ terminal stops it, and it goes back up with the same command:
```sh
# Ctrl-C the `nxdns run` terminal, or `kill` its pid from another shell
nxdns import /tmp/nxdns-lab/rekeyed.zon --force --data-dir /tmp/nxdns-lab/data
nxdns run --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon
nxdns import /tmp/nxdns-lab/rekeyed.zon --data-dir /tmp/nxdns-lab/data
nxdns run --data-dir /tmp/nxdns-lab/data
```
```
imported /tmp/nxdns-lab/rekeyed.zon
```
No flag is needed: replacing a password edits a settings value and deletes no
rows.
On a real install the stop and start are `systemctl stop nxdns` and
`systemctl start nxdns` around the same `import` — **not verified on this
host**, which has no installed nxdns systemd unit (`systemctl status nxdns`
@@ -202,7 +262,7 @@ Once it is back up the old password is refused and the new one works:
```sh
curl -sS -X POST http://127.0.0.1:8451/api/auth/login \
-H 'content-type: application/json' -d '{"password":"a-new-password"}' \
-H 'content-type: application/json' -d '{"password":"lab-password"}' \
-w ' (old password, http %{http_code})\n'
curl -sS -c /tmp/nxdns-lab/c5.txt -X POST http://127.0.0.1:8451/api/auth/login \
-H 'content-type: application/json' -d '{"password":"offline-password"}' \
@@ -217,19 +277,19 @@ curl -sS -b /tmp/nxdns-lab/c5.txt -o /dev/null -w 'stats: %{http_code}\n' \
stats: 200
```
The next export shows the new hash and an empty `password` again:
The next export shows the new hash and a null `password` again:
```sh
nxdns export --data-dir /tmp/nxdns-lab/data | grep password
```
```
.password = "",
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$kvlRj1tdGul3MlfbvzLncLKWirNpJRJ3howFA9/ysgg$7elW7PPQ3WXHwI4YOmOpZ/1KNEQo7ZDLRJhnYOPMjqw",
.password = null,
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$xqzK66LgiWGvyCmCl6ZRa3GHH0nS5qZnRgVfWmeGadc$1mafhflKFIg3vcHjJaDMAXGQiOjtym2UADZsPW1xkfw",
```
`--force` is required because the database already holds configuration. See
[back up and restore](back-up-and-restore.md).
See [back up and restore](back-up-and-restore.md) for when `import` does need
`--allow-delete`.
## What happens with no password set
@@ -279,6 +339,11 @@ interface at the very least, and preferably set a password.
[configuration reference](../reference/configuration.md); the routes are in
the [API reference](../reference/api.md).
Every command on this page was executed on this host as written, except the
`systemctl` stop and start named in step 5 and marked **not verified on this
host** there.
Every command on this page was executed on this host as written, against the
lab described at the top, except the `systemctl` stop and start named in step 5
and marked **not verified on this host** there. That includes the whole of
steps 2 to 5, re-run for this revision: the login, logout and rate-limit
transcripts reproduced exactly as printed, the both-set refusal in step 5 was
reproduced by leaving `.password_hash = ""` in the file, and the rekey then
succeeded once that line was deleted. The cookie jar's expiry timestamp is the
one that run produced and will differ on yours.
+105 -30
View File
@@ -37,10 +37,22 @@ checked on its first line.
**Fixes by cause.**
- `NoUsableUpstreams` — the database has no enabled upstream. On a fresh
install this means the seed file was missing or in the wrong place; the start
log says `no configuration file at '/etc/nxdns/config.zon'; using the
database as it is`. Write the seed file and start again against the still
empty database, or `nxdns import <file> --force`.
install in database mode this is simply an empty database, and the run says
what to do about it on the next line:
```
nxdns run failed: NoUsableUpstreams
run `nxdns check` to see the configuration in full
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
```
Write a configuration file and take either exit: `nxdns import <file>` to load
it into the database once, or add `--config <file>` to `ExecStart` to make the
file the configuration from then on.
- `ManagedConfigUnreadable` — the service runs `run --config FILE` and that file
is missing or the process may not read it. The path is in the FAIL line above
the failure. File mode never falls back to the database, on purpose: a
fallback would turn a bad deploy into a silently stale configuration.
- `BadCertificate` — a DoH or DoT listener is enabled and its certificate or
key is unreadable, too large, unparseable, or the key does not belong to the
certificate. `run` names both paths before it exits:
@@ -79,10 +91,10 @@ checked on its first line.
- `BadBindAddress` — `dns.bind_ipv4` or `dns.bind_ipv6` is not an address of
that family.
## A seed file you just wrote is rejected
## A configuration file you just wrote is rejected
**Symptom.** A first start against an empty database prints the validation
problem and stops with exit 2:
**Symptom.** `nxdns run --config`, `nxdns check --config` or `nxdns import`
prints the validation problem and stops with exit 2:
```
FAIL groups: no group named 'default'; every unknown client is assigned to it
@@ -98,7 +110,7 @@ nxdns run failed: ParseZon
run `nxdns check` to see the configuration in full
```
So does a seed file whose upstream list is empty or all disabled:
So does a file whose upstream list is empty or all disabled:
```
FAIL upstreams: at least one upstream must be enabled
@@ -106,9 +118,9 @@ nxdns run failed: NoUpstreams
run `nxdns check` to see the configuration in full
```
`NoUpstreams` from a seed file is not the same fault as `NoUsableUpstreams`
above: the first is a file `run` refused, the second is a database `run`
accepted and found empty. Both are exit 2.
`NoUpstreams` from a file is not the same fault as `NoUsableUpstreams` above:
the first is a file `run` refused, the second is a database `run` accepted and
found empty. Both are exit 2.
**Diagnosis.** Run the same file through `check`, which reports the same
problems and exits 2:
@@ -117,11 +129,21 @@ problems and exits 2:
nxdns check --config /etc/nxdns/config.zon
```
**Fix.** Correct the file the diagnostics name and start again. The database is
still empty after a failed seed, so the next start re-reads the file. The exit
code no longer depends on which command read the file: all three of these files
were run through `run`, `check` and `import` here, and every one of the nine
combinations exited 2 with the same diagnostic.
**Fix.** Correct the file the diagnostics name and start again. Nothing was
applied — a file-mode reconcile happens in one transaction that rolls back, and
a failed `import` leaves the database untouched. The exit code does not depend
on which command read the file: all three of these files were run through `run`,
`check` and `import` here, and every one of the nine combinations exited 2 with
the same diagnostic.
Under the shipped systemd unit an exit 2 stops the service rather than
restarting it (`RestartPreventExitStatus=2 64`), so the journal holds the
diagnostics instead of drowning them in a restart loop. `systemctl start nxdns`
once the file is fixed.
Make `nxdns check --config <file>` the precondition in whatever pushes the file.
In file mode every boot reads it, so an unvalidated bad push does not fail at
deploy time — it fails at the next restart, which may be a power cut at 3am.
## `nxdns check` fails on a server that is running fine
@@ -210,10 +232,11 @@ startup cycle, not a fix.
## The container restarts in a loop
**Symptom.** `docker compose ps` shows the container restarting, and the log is
one line repeated:
the same failure repeated. Docker has no start limit, so this goes on forever.
```
nxdns run failed: AccessDenied
FAIL /etc/nxdns/config.zon: not readable
nxdns run failed: ManagedConfigUnreadable
```
**Diagnosis.**
@@ -223,10 +246,10 @@ docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}} restarts={{.Restar
stat -c '%a %u:%g %n' deploy/docker/etc-nxdns/config.zon
```
Exit 1 with `AccessDenied` means the container could not read the seed file.
The container runs as uid 65532 and `/etc/nxdns` is mounted read-only, so a
file at mode 0600 owned by your own uid is unreadable to it and the container
cannot repair it.
Exit 2 naming the configuration path means the container could not read the
file the shipped `command:` makes its configuration. The container runs as uid
65532 and `/etc/nxdns` is mounted read-only, so a file at mode 0600 owned by
your own uid is unreadable to it and the container cannot repair it.
**Fix.** Either make the file world-readable, when it holds no secret:
@@ -241,14 +264,65 @@ chown 65532:65532 deploy/docker/etc-nxdns/config.zon
chmod 0600 deploy/docker/etc-nxdns/config.zon
```
The 0644 path was verified here, including the recovery: after the `chmod` the
container started and answered queries. The `chown` needs root and was not run
here.
The 0644 path was verified against an earlier revision of this page, including
the recovery: after the `chmod` the container started and answered queries. The
`chown` needs root and was not run here.
A container that exits 2 instead `nxdns run failed: NoUsableUpstreams` after
`no configuration file at '/etc/nxdns/config.zon'` — has no seed file at all on
a fresh volume. Create `deploy/docker/etc-nxdns/config.zon` and bring it up
again; see [Install with Docker](install-with-docker.md).
`FAIL /etc/nxdns/config.zon: no such file` instead of `not readable` means there
is no configuration file at all. Create `deploy/docker/etc-nxdns/config.zon` and
bring it up again; see [Install with Docker](install-with-docker.md).
A container that exits 2 with `NoUsableUpstreams` is in database mode — the
`command:` line naming `--config` was removed — on a volume whose database is
still empty. Load one and bring it back up:
```sh
docker compose -f deploy/docker/compose.yaml run --rm nxdns import /etc/nxdns/config.zon
```
> Not re-run on this host: staging a release image needs `zig build dist`, which
> could not run here while the web bundle was mid-rebuild by other work in the
> same checkout. The failure text quoted above is what the same binary prints
> outside a container, which was reproduced here, with the container's paths.
## The admin interface refuses an edit with 403
**Symptom.** Saving anything in the admin interface fails, and the API answers:
```json
{"error":"configuration is managed by /etc/nxdns/config.zon; edit the file and restart"}
```
This is not a fault. The service runs `nxdns run --config`, which makes that
file the configuration, and configuration writes through the API are refused so
the file and the running server cannot drift apart.
**Diagnosis.** The start log names the authority:
```sh
journalctl -u nxdns | grep 'authority:'
```
```
info(nxdns): authority: file (/etc/nxdns/config.zon)
```
**Fix.** Edit the file, validate it, restart:
```sh
$EDITOR /etc/nxdns/config.zon
nxdns check --config /etc/nxdns/config.zon
systemctl restart nxdns
```
Or, if you want the interface to be how this box is configured, leave file mode:
drop `--config` from `ExecStart` and restart. The database already holds the
last reconciled state, so nothing is lost. See
[Run in file mode](install-with-systemd.md#run-in-file-mode).
Pausing blocking, refreshing blocklists and reloading certificates are not
configuration and keep working in file mode. Deleting a client works too, unless
the file names that client's address.
## The container cannot reach its upstreams
@@ -324,7 +398,8 @@ window of unfiltered answers.
A generation number with nothing being blocked is a different problem: the
snapshot loaded but has no sources in it. The line
`blocklist snapshot generation 1: 0 of 0 sources loaded` says exactly that. Add
a source in the admin interface, or in the seed file before the first start.
a source in the admin interface, or a `blocklist_sources` entry to the
configuration file with a `group_sources` link naming a group.
## A database stamped by a newer binary
+179 -27
View File
@@ -21,6 +21,105 @@ Upgrading a build you made yourself is the last section of this page.
> shapes and the verification commands are covered by
> [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
exporteditimport 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 exporteditimport 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
+137 -59
View File
@@ -31,6 +31,9 @@ below carries all 56 of its entries.
- Mutations to groups, blocklists, rules, local records, forward zones, clients
and client prefixes take effect live. Upstreams and `/api/settings` are
restart-required.
- Every route has a policy class — `read`, `config_write` or `runtime_action`
and in file mode the `config_write` routes are refused. See
[Configuration authority](#configuration-authority).
## Authentication
@@ -113,70 +116,140 @@ to the capacity is admitted and the long-run rate holds.
holds at most 32 concurrent streams in total; when all slots are taken, the
answer is a 503.
## Configuration authority
Which authority is live decides whether the API may write configuration. Under
`nxdns run` the database is authority and every route behaves as it always has.
Under `nxdns run --config FILE` the file is authority, and the routes that would
edit configuration are refused: the file is the only place configuration
changes, and a restart is what applies them.
### The refusal
A `config write` route in file mode answers **403** with the ordinary error
envelope:
```json
{"error":"configuration is managed by /etc/nxdns/config.zon; edit the file and restart"}
```
There is no `code` field and no richer body. 403 is used for nothing else in
this API, so the status alone is the machine-readable part, and a client that
wants to know the mode in advance reads it from `GET /api/settings` rather than
probing for errors.
**401 comes first.** The router matches the path, spends a rate-limit token,
checks the session, and only then checks the policy. So an unauthenticated
request to a `config write` route in file mode is a 401, not a 403 — answering
403 first would tell an anonymous caller which routes exist.
`runtime action` and `read` routes are unaffected in both modes. Pausing
blocking, refreshing blocklists, reloading certificates and logging in are
operations on a running process, not statements about configuration, so a
file-mode box still does all of them.
`DELETE /api/clients/{id}` is the one route whose answer depends on the row.
Deleting a client the file does not declare is a runtime action and succeeds:
without it, a mis-identified or departed device would be immortal in file mode,
since the file can add addresses but never remove one it has never named.
Deleting a client the file *does* declare contradicts the file, and answers the
same 403.
### Discovering the authority
`GET /api/settings` carries an `authority` object:
| Field | Meaning |
| --- | --- |
| `mode` | `"database"` or `"managed_file"`. |
| `path` | The managed file's path, or `null` in database mode. |
| `reconciled_at` | Unix seconds when this process loaded the file, or `null` in database mode. |
All three keys are always present; the two nullable ones carry `null` rather
than being omitted, so a client can read `authority.mode` without probing.
```json
{"mode": "database", "path": null, "reconciled_at": null}
{"mode": "managed_file", "path": "/etc/nxdns/config.zon", "reconciled_at": 1786474016}
```
The route requires a session, which is why the filesystem path is here rather
than on the open `/api/version` and `/api/health`.
`reconciled_at` answers exactly one question: **when did this process last read
the file?** Compare it against the file's mtime to spot a restart that has not
happened yet. It is a hint and not a verdict, in both directions — a clock that
stepped, or a copy that preserved mtimes (`git checkout`, `rsync -a`), can make
a newer file look older, and the database can change without either timestamp
moving. It does not tell you whether the file and the running configuration
agree; answering that would take content hashing, which nxdns deliberately does
not do.
## Operations
Auth `open` means no session is required; `session` means a valid session cookie
is required whenever a password is set. Rate limit `counted` spends a token;
`exempt` never consults the limiter.
`exempt` never consults the limiter. Policy `config write` is the class refused
in file mode; `read` and `runtime action` are always served.
| Method | Path | Auth | Rate limit | Purpose |
|---|---|---|---|---|
| GET | `/metrics` | open | exempt | Prometheus metrics |
| GET | `/api/health` | open | exempt | Health rollup |
| GET | `/api/version` | open | counted | Build and uptime |
| GET | `/api/openapi.yaml` | open | counted | This API's OpenAPI document |
| POST | `/api/auth/login` | open | counted | Log in |
| POST | `/api/auth/logout` | session | counted | Log out |
| GET | `/api/queries` | session | counted | Query log page |
| GET | `/api/queries/live` | session | exempt | Live query stream (server-sent events) |
| GET | `/api/stats` | session | counted | Totals for a period |
| GET | `/api/stats/timeseries` | session | counted | Bucketed counts for a period |
| GET | `/api/lookup` | session | counted | Explain a domain |
| GET | `/api/upstream/health` | session | counted | Upstream pool health |
| GET | `/api/groups` | session | counted | List groups |
| POST | `/api/groups` | session | counted | Create a group |
| GET | `/api/groups/{id}` | session | counted | Read a group |
| PUT | `/api/groups/{id}` | session | counted | Update a group |
| DELETE | `/api/groups/{id}` | session | counted | Delete a group |
| GET | `/api/groups/{id}/sources` | session | counted | Blocklist sources assigned to a group |
| PUT | `/api/groups/{id}/sources` | session | counted | Replace the assignment |
| GET | `/api/blocklists` | session | counted | List blocklist sources |
| POST | `/api/blocklists` | session | counted | Add a blocklist source |
| POST | `/api/blocklists/update` | session | counted | Refresh every enabled source now |
| GET | `/api/blocklists/{id}` | session | counted | Read a blocklist source |
| PUT | `/api/blocklists/{id}` | session | counted | Update a blocklist source |
| DELETE | `/api/blocklists/{id}` | session | counted | Delete a blocklist source |
| GET | `/api/rules` | session | counted | List rules |
| POST | `/api/rules` | session | counted | Create a rule |
| GET | `/api/rules/{id}` | session | counted | Read a rule |
| PUT | `/api/rules/{id}` | session | counted | Update a rule |
| DELETE | `/api/rules/{id}` | session | counted | Delete a rule |
| GET | `/api/local-records` | session | counted | List local DNS records |
| POST | `/api/local-records` | session | counted | Create a local record |
| GET | `/api/local-records/{id}` | session | counted | Read a local record |
| PUT | `/api/local-records/{id}` | session | counted | Update a local record |
| DELETE | `/api/local-records/{id}` | session | counted | Delete a local record |
| GET | `/api/forward-zones` | session | counted | List forward zones |
| POST | `/api/forward-zones` | session | counted | Create a forward zone |
| GET | `/api/forward-zones/{id}` | session | counted | Read a forward zone |
| PUT | `/api/forward-zones/{id}` | session | counted | Update a forward zone |
| DELETE | `/api/forward-zones/{id}` | session | counted | Delete a forward zone |
| GET | `/api/clients` | session | counted | List clients |
| GET | `/api/clients/{id}` | session | counted | Read a client |
| PUT | `/api/clients/{id}` | session | counted | Rename or regroup a client |
| DELETE | `/api/clients/{id}` | session | counted | Forget a client |
| GET | `/api/client-prefixes` | session | counted | List client prefixes |
| PUT | `/api/client-prefixes` | session | counted | Replace the prefix table |
| GET | `/api/upstreams` | session | counted | List upstream resolvers |
| POST | `/api/upstreams` | session | counted | Add an upstream |
| GET | `/api/upstreams/{id}` | session | counted | Read an upstream |
| PUT | `/api/upstreams/{id}` | session | counted | Update an upstream |
| DELETE | `/api/upstreams/{id}` | session | counted | Delete an upstream |
| GET | `/api/pause` | session | counted | Read the pause state |
| POST | `/api/pause` | session | counted | Pause or resume blocking |
| GET | `/api/settings` | session | counted | Read the scalar settings |
| PUT | `/api/settings` | session | counted | Update settings |
| POST | `/api/certs/reload` | session | counted | Reload the TLS certificates from disk |
| Method | Path | Auth | Rate limit | Policy | Purpose |
|---|---|---|---|---|---|
| GET | `/metrics` | open | exempt | read | Prometheus metrics |
| GET | `/api/health` | open | exempt | read | Health rollup |
| GET | `/api/version` | open | counted | read | Build and uptime |
| GET | `/api/openapi.yaml` | open | counted | read | This API's OpenAPI document |
| POST | `/api/auth/login` | open | counted | runtime action | Log in |
| POST | `/api/auth/logout` | session | counted | runtime action | Log out |
| GET | `/api/queries` | session | counted | read | Query log page |
| GET | `/api/queries/live` | session | exempt | read | Live query stream (server-sent events) |
| GET | `/api/stats` | session | counted | read | Totals for a period |
| GET | `/api/stats/timeseries` | session | counted | read | Bucketed counts for a period |
| GET | `/api/lookup` | session | counted | read | Explain a domain |
| GET | `/api/upstream/health` | session | counted | read | Upstream pool health |
| GET | `/api/groups` | session | counted | read | List groups |
| POST | `/api/groups` | session | counted | config write | Create a group |
| GET | `/api/groups/{id}` | session | counted | read | Read a group |
| PUT | `/api/groups/{id}` | session | counted | config write | Update a group |
| DELETE | `/api/groups/{id}` | session | counted | config write | Delete a group |
| GET | `/api/groups/{id}/sources` | session | counted | read | Blocklist sources assigned to a group |
| PUT | `/api/groups/{id}/sources` | session | counted | config write | Replace the assignment |
| GET | `/api/blocklists` | session | counted | read | List blocklist sources |
| POST | `/api/blocklists` | session | counted | config write | Add a blocklist source |
| POST | `/api/blocklists/update` | session | counted | runtime action | Refresh every enabled source now |
| GET | `/api/blocklists/{id}` | session | counted | read | Read a blocklist source |
| PUT | `/api/blocklists/{id}` | session | counted | config write | Update a blocklist source |
| DELETE | `/api/blocklists/{id}` | session | counted | config write | Delete a blocklist source |
| GET | `/api/rules` | session | counted | read | List rules |
| POST | `/api/rules` | session | counted | config write | Create a rule |
| GET | `/api/rules/{id}` | session | counted | read | Read a rule |
| PUT | `/api/rules/{id}` | session | counted | config write | Update a rule |
| DELETE | `/api/rules/{id}` | session | counted | config write | Delete a rule |
| GET | `/api/local-records` | session | counted | read | List local DNS records |
| POST | `/api/local-records` | session | counted | config write | Create a local record |
| GET | `/api/local-records/{id}` | session | counted | read | Read a local record |
| PUT | `/api/local-records/{id}` | session | counted | config write | Update a local record |
| DELETE | `/api/local-records/{id}` | session | counted | config write | Delete a local record |
| GET | `/api/forward-zones` | session | counted | read | List forward zones |
| POST | `/api/forward-zones` | session | counted | config write | Create a forward zone |
| GET | `/api/forward-zones/{id}` | session | counted | read | Read a forward zone |
| PUT | `/api/forward-zones/{id}` | session | counted | config write | Update a forward zone |
| DELETE | `/api/forward-zones/{id}` | session | counted | config write | Delete a forward zone |
| GET | `/api/clients` | session | counted | read | List clients |
| GET | `/api/clients/{id}` | session | counted | read | Read a client |
| PUT | `/api/clients/{id}` | session | counted | config write | Rename or regroup a client |
| DELETE | `/api/clients/{id}` | session | counted | runtime action | Forget a client |
| GET | `/api/client-prefixes` | session | counted | read | List client prefixes |
| PUT | `/api/client-prefixes` | session | counted | config write | Replace the prefix table |
| GET | `/api/upstreams` | session | counted | read | List upstream resolvers |
| POST | `/api/upstreams` | session | counted | config write | Add an upstream |
| GET | `/api/upstreams/{id}` | session | counted | read | Read an upstream |
| PUT | `/api/upstreams/{id}` | session | counted | config write | Update an upstream |
| DELETE | `/api/upstreams/{id}` | session | counted | config write | Delete an upstream |
| GET | `/api/pause` | session | counted | read | Read the pause state |
| POST | `/api/pause` | session | counted | runtime action | Pause or resume blocking |
| GET | `/api/settings` | session | counted | read | Read the scalar settings |
| PUT | `/api/settings` | session | counted | config write | Update settings |
| POST | `/api/certs/reload` | session | counted | runtime action | Reload the TLS certificates from disk |
There is no `POST /api/clients`: client rows come from DNS activity or import,
never from the API.
@@ -194,6 +267,11 @@ write-only (accepted on a `PUT`, never returned, hashed before storage), and
`web.password_hash` is neither readable nor directly writable, because a client
that could install a hash could install one whose password it already knows.
In file mode `PUT /api/settings` is refused with the 403 above, password changes
included. The password then lives where the rest of the configuration lives: set
`web.password` in the file and restart. See
[Password and hash](configuration.md#password-and-hash).
## Schemas
Request and response schemas for every operation live in the OpenAPI document:
+212 -48
View File
@@ -9,9 +9,10 @@ of truth: `src/cli.zig`.
Every flag takes both spellings, `--flag value` and `--flag=value`. An attached
value that is empty (`--config=`) is a missing value, not an empty path.
`--force` is boolean and takes no value at all, so `--force=1` is not a spelling
of any flag this program has. A flag is rejected by the subcommand that has no
use for it: `--web-dev` outside `run` is an unknown flag, not a no-op.
`--allow-delete` is boolean and takes no value at all, so `--allow-delete=1` is
not a spelling of any flag this program has. A flag is rejected by the
subcommand that has no use for it: `--web-dev` outside `run` is an unknown flag,
not a no-op.
## `run`
@@ -20,12 +21,81 @@ Serves DNS until SIGINT or SIGTERM.
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory (default `/var/lib/nxdns`). Created at mode 0700 if missing. |
| `--config FILE` | Seed configuration file (default `/etc/nxdns/config.zon`). Read only when the database has never been configured. |
| `--config FILE` | Make FILE the sole source of configuration and reconcile the database onto it at every start. No default: without this flag the database is the configuration and no file is read. |
| `--web-dev DIR` | Serve the web interface from DIR instead of the embedded assets, with no cache headers. Development only. |
A seed file that is unparseable, oversized or invalid prints its diagnostics and
exits 2 — the same code `check` and `import` give for the same file. See
[exit codes](#exit-codes).
### Which authority the invocation selects
The presence of `--config` picks the authority, and nothing else does. There is
no default path, no probe of `/etc/nxdns`, and nothing recorded in the database:
a configuration file sitting at `/etc/nxdns/config.zon` that no flag names
changes nothing at all.
| Invocation | Authority | What a start does |
| --- | --- | --- |
| `nxdns run` | The database | Serves what `config.db` holds. Nothing reads a file. |
| `nxdns run --config FILE` | FILE | Reads and validates FILE, reconciles the database onto it, then serves. |
The first log line after the migrations names the mode, so a journal says which
authority was live:
```
info(nxdns): authority: database
info(nxdns): authority: file (/etc/nxdns/config.zon)
```
In file mode the reconcile prints what it changed before that line — per-table
inserted (`+`), updated (`~`) and deleted (`-`) counts, the settings keys whose
values changed, and any change to whether the admin password is set:
```
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +45 ~0 -0;
settings keys changed: dns.bind_ipv4 dns.port web.bind web.port …
web authentication is now enabled
```
A start whose file matches the database writes nothing and says so:
```
reconciled '/etc/nxdns/config.zon': no changes
```
Blocklist state is not declarative and survives every reconcile: a source whose
URL the file still names keeps its row id, its checksum, its counters and its
compiled `<id>.list` and `<id>.wild`, so a restart in file mode downloads
nothing. Editing a source's URL is a new identity — a new row, a new id, and a
fresh download.
### Failing to start in file mode
File mode fails closed. A file that is missing, unreadable, unparseable,
oversized or invalid stops the start; nxdns never falls back to the database,
because a fallback turns a deploy typo into a silently stale configuration.
```
FAIL /etc/nxdns/config.zon: no such file
nxdns run failed: ManagedConfigUnreadable
run `nxdns check` to see the configuration in full
```
That is exit 2, and `check --config` on the same path agrees. Only path-class
open failures map that way — the file is not there, or the process may not read
it. An open that fails for a reason a retry could clear, such as
file-descriptor exhaustion or an I/O error, is exit 1: the box is wrong, not the
configuration. See [exit codes](#exit-codes).
The exit-2 agreement covers `run --config` and `check --config`, which read the
managed file through one shared helper. It does **not** extend to `import`'s
positional argument: a missing file there is `import failed: FileNotFound`, exit
1. That is deliberate rather than an oversight — the managed file is a
declarative input an operator deploys, so its absence is a fact about the
configuration, while `import`'s argument is a path typed at a prompt, and a
mistyped path is a failed command rather than a verdict on anything.
Run `nxdns check --config FILE` before restarting anything that deploys a file.
It grades every declarative fault `run` would hit — read, parse, size,
validation — through the same code, which is what makes it a usable precondition
in an Ansible handler.
## `check`
@@ -52,7 +122,7 @@ first. What it checks, in order:
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory to look for `config.db` in. |
| `--config FILE` | Check this file instead of the database. |
| `--config FILE` | Grade this file instead of the database. |
### Failures and warnings
@@ -71,23 +141,38 @@ The last line is a summary, and it never contradicts the lines above it:
### Source selection
- `--config FILE` given explicitly: check that file, nothing else.
- Otherwise, if `<data-dir>/config.db` exists: check the database — the right
default, since the database is the truth on a configured server. It is opened
immutable, so `check` writes nothing to it; see
[what `check` does not do](#what-check-does-not-do).
- Otherwise, if the default config file path exists: check it.
- Otherwise: "nothing to check", exit 2.
The flag decides, exactly as it does for `run`. There is no fallback and no
probing of a default path:
The first line of output always names which source was checked. A file larger
than 4 MiB fails with `larger than 4194304 bytes`; a ZON syntax error is
- `--config FILE`: grade that file, and never open the database.
- No `--config`: grade `<data-dir>/config.db`. It is opened immutable, so
`check` writes nothing to it; see
[what `check` does not do](#what-check-does-not-do).
So `nxdns check` and `nxdns check --config FILE` grade what the matching `run`
invocation would serve. That is what makes `check` a pre-restart gate rather
than an approximation of one.
A bare `check` on a box with no database says so and names both ways out of the
state, exit 2:
```
no config database at /var/lib/nxdns/config.db
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
```
The first line of output otherwise always names which source was checked. A file
larger than 4 MiB fails with `larger than 4194304 bytes`; a ZON syntax error is
reported with its line and column.
A named file that is missing or unreadable is a finding like any other, not an
I/O failure that escapes the run: `FAIL <path>: no such file` or
`FAIL <path>: not readable`, exit 2. That is the same code the implicit path
gives when there is nothing to check, so naming the file does not change what a
missing file costs.
`FAIL <path>: not readable`, exit 2.
What `check --config` cannot see is the reconcile itself. It needs no database,
so faults that only a write can produce — a disk that is full, a lock held by a
restart that started first — are invisible to it. Those are runtime failures,
exit 1, and they are not verdicts on the file.
### What `check` does not do
@@ -133,6 +218,15 @@ there. A directory that exists without a `config.db` is not that case: the
database is opened with create semantics, so an empty `config.db` is created and
migrated, and the export is of a default configuration.
The output is the same in both authority modes — nothing marks a file as
exported from a file-mode box. That is what lets `export` be the adoption tool:
the file you check is the file you deploy.
`web.password` is always written as `null` and `web.password_hash` carries the
stored value, so an export re-imports without anyone knowing the password. A
`null` password is not the same statement as an empty one: see
[`web.password` and `web.password_hash`](configuration.md#password-and-hash).
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory holding `config.db`. |
@@ -142,25 +236,79 @@ See [back up and restore](../how-to/back-up-and-restore.md).
## `import FILE`
Validates FILE and replaces the whole configuration with it in one transaction.
Prints every validation problem; a failed import leaves the database untouched.
Refuses a database that already holds configuration unless `--force` is given
(`DatabaseNotEmpty`, exit 2). The check measures what an operator set, not what
the network did: client rows the DNS path materialised from traffic never
trigger the refusal on their own. Creates the data directory at mode 0700 if it
is missing.
Client history survives the replacement. An address the database already knew
keeps its first-seen and last-seen even when FILE names it; only an address it
has never seen takes the import's clock. A client FILE leaves out is removed,
history included.
Converges the database onto FILE in one transaction — the same reconcile a
file-mode `run` performs, done once from the command line. Prints every
validation problem; a failed import leaves the database untouched. Creates the
data directory at mode 0700 if it is missing.
`FILE` is positional and may appear before or after the flags.
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory holding `config.db` (created if missing). |
| `--force` | Replace a database that already holds configuration. |
| `--allow-delete` | Apply a file whose diff deletes rows. |
**`import` is a stop-first operation.** It rewrites configuration underneath a
process that read it at startup, and a running server notices only some of it:
filtering picks up the imported rows at the next reload, while upstreams,
listeners and settings stay at their boot values until a restart.
### The delete gate
Rows the database holds and FILE does not name are deleted. That is the point of
a declarative apply, and it is also how a mistaken `nxdns import ./wrong.zon`
empties a configured server, so it takes a flag:
```
FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it
import failed: DestructiveImport
```
That is exit 2, and the transaction rolls back. The message names every table
with a non-zero delete count, so you can tell an intended pruning from a wrong
file before applying anything.
An import that only adds rows, or only edits them, needs no flag. "Edit" here
means a change to a row nxdns can still recognise as the same row. Each table
has one column, or one tuple, that establishes identity:
| Table | Identity |
| --- | --- |
| `blocklist_sources`, `upstreams` | `url` |
| `groups` | `name` |
| `clients` | `ip` |
| `client_prefixes` | `prefix` |
| `forward_zones` | `zone` |
| `local_records` | `(name, rtype, value)` |
| `rules` | `(group, pattern, kind, action)` |
Change anything else on a row — a source's name, a group's `safe_search`, a
client's group — and it is an edit, applied without a flag. Change the identity
itself, such as renaming a group or correcting a typo in an upstream URL, and
the engine sees a row that vanished and a row that appeared: that needs
`--allow-delete`.
### What survives an import
Runtime state is not declarative and is preserved by identity, not by luck. A
blocklist source whose URL is unchanged keeps its row id, its checksum, its
counters and its compiled files, so an import costs no downloads. Clients the
DNS path materialised from traffic are kept whole; naming one in the file
promotes that row in place, keeping its first-seen and last-seen. Observed
clients whose group the file no longer declares are moved to the `default`
group rather than deleted with it, and none of that ever trips the delete gate.
### `import` against a file-mode box
It behaves like any other import. Nothing in the database records that a file
governs it — authority lives in the invocation — so `import` neither detects nor
refuses that case. The next restart's reconcile converges the database back to
the file and its summary reports what it corrected. A source the import deleted
comes back with a new row id, which means a fresh download of the whole list.
If a restart and an import race for the write lock, one of them simply wins: both
take `BEGIN IMMEDIATE` under a 5-second busy timeout, so the outcome is an
ordering, never a corrupted database.
## `version`
@@ -185,28 +333,44 @@ Code 2 means the same thing from every subcommand. `src/config/faults.zig`
holds the one list of errors that mean "the configuration the operator supplied
is wrong", and `run`, `check` and `import` all ask it, so a rejected file exits
2 whichever command read it. The list is every error the validator raises, plus
`ParseZon`, `ConfigTooLarge`, `NoUsableUpstreams` and `BadCertificate`. In
practice that covers a seed file with a syntax error, one larger than 4 MiB, one
with no `default` group (`MissingDefaultGroup`), one with no enabled upstream
(`NoUpstreams`), a bad bind address, a bad rate limit, an unusable certificate,
and `password` and `password_hash` set together.
`ParseZon`, `ConfigTooLarge`, `NoUsableUpstreams`, `BadCertificate` and
`ManagedConfigUnreadable`. In practice that covers a file with a syntax error,
one larger than 4 MiB, one with no `default` group (`MissingDefaultGroup`), one
with no enabled upstream (`NoUpstreams`), a bad bind address, a bad rate limit,
an unusable certificate, `password` and `password_hash` set together, and a
`--config` path that is absent or unreadable.
When `run` exits 2 it points at the diagnosis on stderr, whether the fault came
from the seed file or from the database it loaded:
The last of those is the one deliberate seam. A file nxdns cannot open is a
configuration fault only when the *path* is the problem — the file is missing,
permissions deny it, a path component is not a directory. Every other open
failure, such as running out of file descriptors, is exit 1. The distinction
earns its keep under the shipped systemd unit, which stops the service on exit 2
rather than restarting it: a transient box fault graded as a configuration fault
would take the resolver down until someone noticed.
When `run` exits 2 it points at the diagnosis on stderr, whichever authority the
fault came from:
```
run `nxdns check` to see the configuration in full
```
`check` exits 2 for those faults and also when a probed upstream failed, when a
named configuration file is missing or unreadable, when the database cannot be
read or is not at this binary's schema version, and when there was nothing to
check. Warnings never contribute.
On a box with no configuration at all, `run` and `check` add the line that names
both ways to get one:
`import` exits 2 for those faults and for `DatabaseNotEmpty`. That last one is
deliberately not a configuration fault — it reports the state of the database
rather than the content of a file — and `import` decides it for itself; the
answer to it is `--force`, not an edit.
```
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
```
`check` exits 2 for those faults and also when a probed upstream failed, when a
named configuration file is missing or unreadable, and when the database cannot
be read, is absent, or is not at this binary's schema version. Warnings never
contribute.
`import` exits 2 for those faults and for `DestructiveImport`. That last one is
deliberately not a configuration fault — it reports what applying the file would
delete, rather than anything wrong with its content — and `import` decides it
for itself; the answer to it is `--allow-delete`, not an edit.
`OutOfMemory` is exit 1 even when problems were recorded, because the report is
then incomplete. Every other error is 1.
+50 -18
View File
@@ -4,7 +4,7 @@ Every section, field and collection nxdns accepts, with its type, default,
unit, validation rule and the subsystem that consumes it.
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 | 165535 (0 is refused) | web listener port |
| `web.password` | string | `""` | — | must not be set together with `web.password_hash` | operator input only; hashed at import and discarded. Never a settings row — see [Password and hash](#password-and-hash) |
| `web.password_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,
+18 -9
View File
@@ -10,11 +10,9 @@ snapshots), `src/platform/logging.zig` (the log file).
Default `/var/lib/nxdns`, overridable with `--data-dir DIR`. `nxdns run` and
`nxdns import` create it and its parents at mode 0700 when it is missing;
`nxdns check` and `nxdns export` do not create it. `export` fails if it is not
there. `check` opens it only on the branch that resolved to the database, so an
absent data directory is not in itself a failure: an explicit `--config FILE`
never looks at the directory, and without one `check` falls back to the default
configuration file, or prints "nothing to check" and exits 2 when neither source
exists.
there. `check` opens it only when no `--config FILE` was given: with that flag it
grades the file and never looks at the directory at all. Without it, an absent
`config.db` is a failure naming the two ways to get one, exit 2.
`run`, `import` and `export` go through `DataDir.openConfigDb`, which opens
`config.db` read/write, chmods it to 0600, enables WAL — creating
@@ -39,7 +37,7 @@ older ones from the main file. That is the "uncheckpointed changes" failure in
| Path | What it is | Mode |
| --- | --- | --- |
| `config.db` | The configuration database — the single source of truth, including `web.password_hash`. | 0600 |
| `config.db` | The configuration database, including `web.password_hash`. The source of truth in database mode; in file mode it is the runtime substrate the file is reconciled onto (see [the configuration file](#the-configuration-file)). | 0600 |
| `config.db-wal`, `config.db-shm` | SQLite write-ahead log and shared-memory index for `config.db`. Created by `run`, `import` and `export` when WAL is enabled, inheriting the main file's permissions. `check` creates neither. | 0600 |
| `querylog.db` | The query log: every domain every client asked for. Expendable — if it is missing or unusable it is recreated empty. | 0600 |
| `querylog.db-wal`, `querylog.db-shm` | WAL sidecars for `querylog.db`. | 0600 |
@@ -112,9 +110,20 @@ than being created world-readable.
## The configuration file
Default `/etc/nxdns/config.zon`, overridable with `--config FILE`. nxdns reads
it and never writes it: it seeds an unconfigured database once and is ignored
afterwards. nxdns does not create the file or its directory.
There is no default path. `--config FILE` names the file, and without that flag
no file is read at all — a `config.zon` sitting in `/etc/nxdns` that no
invocation names is inert. `/etc/nxdns/config.zon` is a convention the packaging
follows, not a location nxdns probes.
nxdns reads the file and never writes it, in either authority mode. It does not
create the file or its directory either; the systemd unit's
`ConfigurationDirectory=nxdns` creates `/etc/nxdns`, and the same unit's
`ReadOnlyPaths=/etc/nxdns` denies the service write access to it, so the file
cannot be modified by the process that reads it.
Under `run --config FILE` the file is the configuration and the database is the
runtime substrate the server reads from: every start reconciles the one onto the
other. So in that mode `config.db` is not the backup — the file is.
`nxdns export --out FILE` writes a ZON file at mode 0600 through a temporary
file and a rename. That file carries `web.password_hash`, so treat exports as
+98 -36
View File
@@ -9,8 +9,13 @@ delete the directory.
Follow the steps in order. Each one says what it did.
Every command below was executed on x86_64 Linux with Zig 0.16.0, Node.js
24.14.1, dig 9.20.26 and curl 8.21.0. The only thing substituted during that run
was the tutorial directory.
24.14.1, dig 9.20.26 and curl 8.21.0. Steps 4 to 11, 13 and 14 were re-run end
to end for this revision, and the transcripts are that run's output with the
tutorial directory substituted. Two things were not re-run: the browser page in
step 12 — its endpoints were exercised, the page itself was not opened — and the
two build commands in steps 1 and 2, which had already produced the binary under
test. The ZON block at the end of step 14 was checked with `nxdns check
--config` rather than started.
## What you need
@@ -30,7 +35,7 @@ cd web && npm ci && npm run build && cd ..
```
This produces `web/dist`. Do not skip it. A plain `zig build` embeds
`web/dist-placeholder`, a one-page status stub, and you would reach step 10 and
`web/dist-placeholder`, a one-page status stub, and you would reach step 12 and
find no admin interface there.
## 2. Build nxdns
@@ -75,6 +80,11 @@ itself, so a configuration missing either one is rejected. Whichever command
reads the file says the same thing and stops the same way: `run`, `nxdns check`
and `nxdns import` all print the problem and exit 2.
This tutorial loads the file into the database once and then runs nxdns against
the database, which is what the packaged systemd unit does. There is a second
way to run nxdns, where the file itself stays the configuration; step 14 shows
what changes.
## 4. Check the configuration before starting
```sh
@@ -92,33 +102,44 @@ answers. It exits 2 when it found something that has to be fixed and 0
otherwise. It writes nothing and starts no listener, so you can run it as often
as you like.
## 5. Start the server
In your first terminal:
## 5. Load it into the database
```sh
zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data --config ~/nxdns-tutorial/config.zon
zig-out/bin/nxdns import ~/nxdns-tutorial/config.zon --data-dir ~/nxdns-tutorial/data
```
```
info(migrations): config.db migrated from schema version 0 to 2
info(config_bootstrap): seeded the database from '/home/you/nxdns-tutorial/config.zon'
imported /home/you/nxdns-tutorial/config.zon
```
The data directory did not exist; nxdns created it at mode 0700 along with
`config.db`. From here the database holds the configuration, and you will change
it through the API rather than by editing the file again.
## 6. Start the server
In your first terminal:
```sh
zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data
```
```
info(querylog_schema): created querylog database '/home/you/nxdns-tutorial/data/querylog.db'
info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 199 bytes
info(nxdns): authority: database
info(nxdns): nxdns <version> serving on udp [::1]:15353 udp 127.0.0.1:15353 tcp [::1]:15353 tcp 127.0.0.1:15353; 1 upstream(s); blocklist generation 1
info(web_server): web interface listening on 127.0.0.1:8080
```
The data directory did not exist; nxdns created it at mode 0700 along with
`config.db` and `querylog.db`. The line that matters is `seeded the database
from`: the configuration file was read into the database this once. From now on
the database is the truth and the file is ignored on every later start, which
you will see for yourself in step 11. Configuration changes go through the API,
the web interface, or `nxdns import`.
No `--config` here, and that is the point: `authority: database` says the
database is the configuration and no file was opened at all. The file you wrote
in step 3 has done its job.
Leave this terminal running and switch to the second one.
## 6. Resolve a name
## 7. Resolve a name
```sh
dig @127.0.0.1 -p 15353 example.com A +noall +answer
@@ -131,10 +152,10 @@ example.com. 90 IN A 104.20.23.154
nxdns had no answer cached, so it forwarded the query to
`https://cloudflare-dns.com/dns-query` over HTTPS and returned what came back.
You now have a working resolver. It blocks nothing yet: the log line in step 5
You now have a working resolver. It blocks nothing yet: the log line in step 6
said `0 of 0 sources loaded`.
## 7. Add a blocklist source
## 8. Add a blocklist source
```sh
curl -s -X POST http://127.0.0.1:8080/api/blocklists \
@@ -152,7 +173,7 @@ That is fine for a localhost tutorial and wrong for anything else; see
Note the `"id":1` in the response. You need it in the next step.
## 8. Attach the source to the `default` group
## 9. Attach the source to the `default` group
A blocklist source belongs to the installation. Which groups use it is a
separate decision, which is what lets one group get a strict list and another
@@ -190,7 +211,7 @@ curl -s -X PUT http://127.0.0.1:8080/api/groups/1/sources \
{"source_ids":[1]}
```
## 9. Download the list
## 10. Download the list
Adding a source registers it; it does not fetch it. Ask for a refresh:
@@ -199,25 +220,25 @@ curl -s -X POST http://127.0.0.1:8080/api/blocklists/update
```
```json
{"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1785683777,"last_success":1785683777,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":99277,"wildcards":0,"skipped_regex":0}]}
{"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1786473715,"last_success":1786473715,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":99559,"wildcards":0,"skipped_regex":0}]}
```
The download is about 3 MB and takes a few seconds. Watch the first terminal
until this appears:
```
info(blocklist_manager): blocklist snapshot generation 5: 1 of 1 sources loaded, 3088703 bytes
info(blocklist_manager): blocklist snapshot generation 6: 1 of 1 sources loaded, 3096006 bytes
```
`1 of 1 sources loaded` is the line to wait for. nxdns builds each blocklist
snapshot in full and swaps it in atomically, so queries keep being answered from
the previous snapshot the whole time the new one is being built. After this, the
domain count in the JSON above — 99277 on the day this was run — is live.
domain count in the JSON above — 99559 on the day this was run — is live.
From here on, the list is on disk under `~/nxdns-tutorial/data/blocklists`.
Restarting nxdns does not re-download it.
## 10. Watch a domain get blocked
## 11. Watch a domain get blocked
```sh
dig @127.0.0.1 -p 15353 doubleclick.net A +noall +answer
@@ -249,7 +270,7 @@ yourself can, with a wildcard pattern such as `*.doubleclick.net`. So when you
pick a domain to test, pick one that is literally in the file.
`www.google-analytics.com` is another that is.
## 11. Open the web interface
## 12. Open the web interface
Visit <http://127.0.0.1:8080> in a browser. This is the single-page application
you built in step 1, served out of the binary. The dashboard shows query and
@@ -257,7 +278,7 @@ block counts, and the Blocklists page shows the source you added with its
domain count. (The endpoints behind those two pages were checked while writing
this; the browser page itself was not opened on the verification host.)
## 12. Stop it
## 13. Stop it
In the first terminal, press Ctrl-C.
@@ -267,21 +288,61 @@ info(nxdns): shutting down
nxdns catches SIGINT and SIGTERM, stops serving and exits 0.
Start it again with the same command as in step 5 and read the first log line:
Start it again with the same command as in step 6 and read the first log lines:
```
info(config_bootstrap): configuration file ignored; the database is already configured
info(blocklist_manager): blocklist snapshot generation 1: 1 of 1 sources loaded, 3088703 bytes
info(blocklist_manager): blocklist snapshot generation 1: 1 of 1 sources loaded, 3096006 bytes
info(nxdns): authority: database
```
The configuration file was not even opened, and the blocklist came off disk
rather than the network. The blocklist source, the group it is attached to, and
the query log all survived the restart because they live in
`~/nxdns-tutorial/data`.
The blocklist came off disk rather than the network. The source you added, the
group it is attached to, and the query log all survived the restart because they
live in `~/nxdns-tutorial/data`, which is what `authority: database` means in
practice.
Press Ctrl-C again to stop this second process. Nothing is listening on 15353 or
8080 now, and nothing of nxdns is running.
## 14. See what the other mode does
You have been running in database mode. The alternative is to hand the same file
back as the configuration, which is what `--config` means:
```sh
zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data --config ~/nxdns-tutorial/config.zon
```
```
reconciled '/home/you/nxdns-tutorial/config.zon': sources +0 ~0 -1; group_sources +0 ~0 -1;
info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 199 bytes
info(nxdns): authority: file (/home/you/nxdns-tutorial/config.zon)
```
**Read that first line.** The blocklist source is gone. That is not a bug — it is
the whole contract. The file you wrote in step 3 never mentioned a blocklist
source, and in file mode the file is the complete statement of what the
configuration is, so anything the database holds that the file does not name is
removed at every start. The reconcile said so in one line before doing it.
The compiled list is still on disk and the query log is untouched; what changed
is the configuration, and it now matches the file exactly.
Neither mode is the "advanced" one. Database mode suits a box someone
administers through the web interface. File mode suits a file kept in git and
deployed by a tool, where the deployed file being what is running matters more
than clicking. To have kept the blocklist here, you would put it in the file:
```zon
.blocklist_sources = .{
.{ .url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", .name = "StevenBlack hosts" },
},
.group_sources = .{
.{ .group = "default", .source_url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts" },
},
```
Ctrl-C to stop it.
## What you have now
A resolver that answers real queries, a real blocklist of about 99000 domains
@@ -292,9 +353,10 @@ directory you can delete:
rm -rf ~/nxdns-tutorial
```
You also saw the two things that surprise people most: the configuration file
seeds the database once and is ignored afterwards, and a blocklist source does
nothing until a group uses it.
You also saw the three things that surprise people most: a blocklist source
does nothing until a group uses it, which authority a start runs under is
printed rather than guessed, and in file mode anything the file does not name is
removed at the next start.
## Where to go next
@@ -305,4 +367,4 @@ nothing until a group uses it.
- [reference/configuration.md](../reference/configuration.md) — every field you
did not set.
- [explanation/configuration-model.md](../explanation/configuration-model.md) —
why the file seeds and the database rules.
why there are two authority modes and what each one is for.
+49 -22
View File
@@ -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=<file>` on a DB with fetched blocklists (any boot
- [x] `nxdns run --config=<file>` on a DB with fetched blocklists (any boot
after adoption): restart performs zero downloads **and zero DB writes**;
source ids, checksums, `last_updated`, and compiled
`<id>.list`/`<id>.wild` files are identical before and after. Proven
once with `-Dlive` against a real source.
- [ ] 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=<file>` with a missing file exits 2 with the path in
- [x] `run --config=<file>` with a missing file exits 2 with the path in
the message; with an invalid file exits 2 with diagnostics; never serves
from the DB. An open failure outside the path class (fd exhaustion, I/O
error) exits 1, not 2. `check --config=<file>` agrees with `run`
@@ -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
+443 -70
View File
@@ -32,7 +32,6 @@ const tls = std.crypto.tls;
const api_limiter = @import("web/api_limiter.zig");
const auth = @import("web/auth.zig");
const bootstrap = @import("config/bootstrap.zig");
const cert_store = @import("server/cert_store.zig");
const cli = @import("cli.zig");
const clients = @import("server/clients.zig");
@@ -49,6 +48,7 @@ const fetcher = @import("filter/fetcher.zig");
const forward_zones = @import("local/forward_zones.zig");
const handler = @import("server/handler.zig");
const http_util = @import("web/http_util.zig");
const loader = @import("config/loader.zig");
const local_records = @import("local/records.zig");
const local_tables = @import("server/local_tables.zig");
const logger_mod = @import("storage/logger.zig");
@@ -60,6 +60,7 @@ const pause = @import("server/pause.zig");
const pool_mod = @import("upstream/pool.zig");
const query_sink = @import("server/query_sink.zig");
const rate_limiter = @import("server/rate_limiter.zig");
const reconcile = @import("config/reconcile.zig");
const retention_mod = @import("storage/retention.zig");
const safe_url = @import("safe_url.zig");
const shutdown = @import("server/shutdown.zig");
@@ -95,6 +96,12 @@ pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 {
const mapped = failureExitCode(err);
if (mapped == cli.exit_check) {
runner.err.writeAll("run `nxdns check` to see the configuration in full\n") catch {};
// Ruling 6: after bootstrap seeding died, a fresh database fails
// validation naturally (`NoUsableUpstreams`) and the operator needs
// to be told how a database gets a configuration at all. In file
// mode they already have a file, and the diagnostics above name what
// is wrong with it.
if (args.config == null) cli.writeDbSourceHint(runner.err);
}
break :code mapped;
};
@@ -113,70 +120,201 @@ fn failureExitCode(err: anyerror) u8 {
return if (faults.isConfigFault(err)) cli.exit_check else cli.exit_runtime;
}
/// First run only: the file seeds an empty database and is ignored forever
/// after. Its diagnostics are the operator's one chance to see what the file
/// said, so they are printed the way `check` and `import` print them.
/// File authority (ruling 2): read the file the operator named, validate it, and
/// converge the database onto it — on this start and on every start after it.
/// Returns the wall clock the reconcile ran at, which becomes the settings
/// envelope's `reconciled_at`.
///
/// Printed on the way out either way. A seed file can be accepted and still
/// carry warnings — a blocklist source in no group is downloaded and compiled
/// into nothing — and a warning that only appears when the start fails is a
/// warning nobody ever reads: the start it describes is the one that worked.
/// `check` reported it and `run` did not, which left the same file graded two
/// ways.
/// A missing, unreadable or invalid file fails the start. There is no fallback
/// to the database under any failure: a fallback turns a deploy typo into a
/// silently stale configuration, which is the failure mode the whole mode exists
/// to prevent.
///
/// The runner's error writer, not `std.log`: this runs before
/// `logging.install`, and one rendering of a diagnostic across `run`, `check`
/// and `import` is the point of `Diagnostics.writeAll`.
/// Diagnostics are printed on the way out either way. A file can be accepted and
/// still carry warnings — a blocklist source in no group is downloaded and
/// compiled into nothing — and a warning that only appears when the start fails
/// is a warning nobody ever reads: the start it describes is the one that
/// worked.
///
/// Flushed here rather than left to `run`'s exit flush. That writer is buffered
/// (`main` gives it 4 KiB) and `serve` does not return for as long as the
/// service runs, so a line left in the buffer reaches the operator when the
/// The runner's writers, not `std.log`: this runs before `logging.install`, and
/// one rendering of a diagnostic across `run`, `check` and `import` is the point
/// of `Diagnostics.writeAll`.
///
/// Flushed here rather than left to `run`'s exit flush. Both writers are
/// buffered (`main` gives them 4 KiB) and `serve` does not return for as long as
/// the service runs, so a line left in the buffer reaches the operator when the
/// process stops — days after the start it describes. A failure path flushes
/// anyway because it returns immediately; the successful start is the one that
/// needs this.
fn seedFromFile(
fn reconcileFromFile(
r: cli.Runner,
config_db: *db.Db,
dir: std.Io.Dir,
config_path: []const u8,
) bootstrap.Error!bootstrap.Outcome {
) !i64 {
return reconcileFromFileAt(
r,
config_db,
dir,
config_path,
std.Io.Clock.real.now(r.io).toSeconds(),
);
}
/// `pass_now` is what the engine stamps into the runtime columns of the rows it
/// inserts (`first_seen`, `last_seen`, `created_at`), and it is deliberately not
/// the value this returns.
///
/// The two clocks answer different questions, and conflating them was a real
/// defect: `reconciled_at` means "this process loaded the file at T" and is
/// compared against the file's mtime to detect a restart-pending state
/// (ruling 7). A stamp taken *before* the read makes a file written during the
/// read look newer than the process that loaded it — a false "restart pending"
/// in the UI for a file that is fully applied. So this returns a clock read
/// taken immediately after the commit, and `pass_now` never leaves the engine.
///
/// Split from `reconcileFromFile` so the two are separable in a test: pin
/// `pass_now` and the returned stamp must still be the real clock.
fn reconcileFromFileAt(
r: cli.Runner,
config_db: *db.Db,
dir: std.Io.Dir,
config_path: []const u8,
pass_now: i64,
) !i64 {
var arena_state: std.heap.ArenaAllocator = .init(r.gpa);
defer arena_state.deinit();
var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit();
const result = bootstrap.bootstrap(r.io, r.gpa, config_db, dir, config_path, &diags);
const result = applyManagedFile(r, config_db, dir, config_path, arena_state.allocator(), &diags, pass_now);
// Neither discard is an oversight, and the two answer different questions.
//
// On a rejected seed file, `result` is returned untouched: the operator gets
// the reason the start failed, never a writer error standing in front of it.
// A broken stderr is not why the configuration was refused.
//
// On a seed that worked, a failure here does not stop the start. The trade
// is one lost warning line against a household with no name resolution, and
// `run` before this point is the only stretch of this program where an
// output failure could take DNS down at all — ruling 4 already says nothing
// after it is fatal. Nor could the failure be reported: this writer *is* the
// error channel, and `logging.install` has not run yet, so `std.log` resolves
// to the same stderr a diagnostic about it would have to travel down.
//
// It is not lost from the process either. A failed drain consumes nothing,
// so whatever the buffer held it still holds — that half is observed, in
// "a broken error writer does not stop a first start that succeeded" below,
// which reads the retained warning back out of the same writer.
//
// What happens to those bytes afterwards is derived, not watched, and is
// labelled so deliberately. `Io.Writer.defaultFlush` drains while `end != 0`
// and `run`'s exit flush maps a failure to exit 1, so a stderr still broken
// at shutdown should carry the condition out in the exit code, and one that
// recovered should deliver the line late. No test drives `run` that far.
// Two limits come with the derivation: an empty buffer flushes clean and
// reports nothing at all, and a failure that recovers ends at exit 0 with a
// line the operator reads days after the start it describes.
// Both discards are deliberate, and they answer different questions. On a
// rejected file the operator gets the reason the start failed, never a
// writer error standing in front of it — a broken stderr is not why the
// configuration was refused. On a file that applied, a failure here does not
// stop the start: the trade is one lost warning line against a household
// with no name resolution, and this writer *is* the error channel, so the
// failure has nowhere to be reported anyway.
diags.writeAll(r.err) catch {};
r.err.flush() catch {};
return result;
}
/// Returns the moment the transaction committed, which is what the settings
/// envelope reports as `reconciled_at`.
fn applyManagedFile(
r: cli.Runner,
config_db: *db.Db,
dir: std.Io.Dir,
config_path: []const u8,
arena: Allocator,
diags: *validate.Diagnostics,
now: i64,
) !i64 {
const cfg = try loader.load(r.io, arena, dir, config_path, diags);
try validate.validate(cfg, diags);
// The keys this pass wrote, never their values (ruling 8). Duplicated into
// `gpa` by the engine, so this frame frees them.
var changed: std.ArrayList([]const u8) = .empty;
defer {
for (changed.items) |key| r.gpa.free(key);
changed.deinit(r.gpa);
}
var pass = reconcile.begin(r.io, r.gpa, config_db, cfg, now, .{
.changed_settings = &changed,
}) catch |err| {
// `begin` has already rolled its own transaction back. What the operator
// needs is the cause: a full SD card must read as "disk", not as a bare
// exit 1, so the SQLite condition is named.
reportReconcileFailure(r, config_db, config_path, err);
return err;
};
errdefer pass.rollback();
pass.commit() catch |err| {
reportReconcileFailure(r, config_db, config_path, err);
return err;
};
// Read here and nowhere earlier: the file is loaded once this line runs, and
// not one statement before it.
const reconciled_at = std.Io.Clock.real.now(r.io).toSeconds();
printSummary(r, config_path, pass.summary, changed.items) catch {};
return reconciled_at;
}
/// SQLite conditions an operator acts on differently. `@errorName` alone would
/// say `Full`, which is not a word anyone can search for; the primary result
/// code's own name is.
fn sqliteCodeName(err: anyerror) ?[]const u8 {
return switch (err) {
error.Full => "SQLITE_FULL",
error.Busy => "SQLITE_BUSY",
error.IoErr => "SQLITE_IOERR",
error.ReadOnly => "SQLITE_READONLY",
error.Corrupt => "SQLITE_CORRUPT",
error.Constraint => "SQLITE_CONSTRAINT",
else => null,
};
}
fn reportReconcileFailure(r: cli.Runner, config_db: *db.Db, config_path: []const u8, err: anyerror) void {
var buf: [256]u8 = undefined;
const detail = config_db.lastError(&buf);
const code = sqliteCodeName(err) orelse @errorName(err);
r.err.print("reconciling '{s}' failed: {s}: {s}\n", .{ config_path, code, detail }) catch {};
r.err.flush() catch {};
}
/// What that restart changed, without opening sqlite (ruling 8): per-table
/// counts, the settings keys that moved — never their values — and an
/// authentication change, which is never a silent line item in a count.
fn printSummary(
r: cli.Runner,
config_path: []const u8,
summary: reconcile.Summary,
changed_settings: []const []const u8,
) !void {
try r.out.print("reconciled '{s}':", .{config_path});
if (summary.isNoOp()) {
try r.out.writeAll(" no changes\n");
} else {
inline for (@typeInfo(reconcile.Summary).@"struct".fields) |field| {
if (field.type == reconcile.TableCounts) {
const counts = @field(summary, field.name);
if (counts.total() != 0) {
try r.out.print(" {s} +{d} ~{d} -{d};", .{
field.name,
counts.inserted,
counts.updated,
counts.deleted,
});
}
}
}
try r.out.writeAll("\n");
if (changed_settings.len != 0) {
try r.out.writeAll("settings keys changed:");
for (changed_settings) |key| try r.out.print(" {s}", .{key});
try r.out.writeAll("\n");
}
switch (summary.auth_transition) {
.none => {},
.enabled => try r.out.writeAll("web authentication is now enabled\n"),
.disabled => try r.out.writeAll("web authentication is now disabled\n"),
.rotated => try r.out.writeAll("the web password changed\n"),
}
}
try r.out.flush();
}
fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
const io = r.io;
const gpa = r.gpa;
@@ -193,7 +331,15 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
defer config_db.close();
_ = try migrations.migrate(&config_db);
_ = try seedFromFile(r, &config_db, std.Io.Dir.cwd(), paths.config);
// Ruling 1: the presence of `--config` is the whole authority decision. With
// it, the file is the sole declarative source and the database is converged
// onto it here, before anything reads the database. Without it the database
// is authority and this step does not exist — a file on disk that no flag
// names changes nothing.
const reconciled_at: ?i64 = if (args.config) |config_path|
try reconcileFromFile(r, &config_db, std.Io.Dir.cwd(), config_path)
else
null;
// Every string in `cfg` points into this arena, and the pool's endpoints,
// the handler's records and the monitor's paths all keep such strings. It
@@ -203,6 +349,17 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
defer arena_state.deinit();
const arena = arena_state.allocator();
// The web layer reads the managed path out of `WebState` for the whole life
// of the process, so it takes a copy from the arena that outlives it rather
// than borrowing `argv`.
const authority: web_server.Authority = if (args.config) |config_path|
.{ .managed_file = try arena.dupe(u8, config_path) }
else
.database;
// The database stays the runtime substrate and the effective-config read
// path in both modes: in file mode the reconcile above has just made it
// agree with the file.
const cfg = try config_export.readConfig(&config_db, arena);
// From here on `std.log` goes wherever the operator asked. Before this call
@@ -472,7 +629,9 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
if (cfg.web.enabled) web_state = .{
.gpa = gpa,
.web = cfg.web,
.live_hash = .init(cfg.web.password_hash),
.authority = authority,
.reconciled_at = reconciled_at,
.live_hash = .init(cfg.web.password_hash orelse ""),
.handler = &h,
.pause = &paused,
.tracker = &tracker,
@@ -602,7 +761,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// this box exists for — keeps serving.
if (cfg.web.enabled) try group.concurrent(io, web_server.serve, .{ &web_state, io });
logStartup(io, &manager, upstreams.active().len, .{
logStartup(io, authority, &manager, upstreams.active().len, .{
.udp6 = if (udp6) |*s| s.boundAddress() else null,
.udp4 = if (udp4) |*s| s.boundAddress() else null,
.tcp6 = if (tcp6) |*s| s.boundAddress() else null,
@@ -1005,8 +1164,8 @@ test "run, check and import agree on a seed file with no default group" {
\\}
;
// `run`: `serve` seeds through `bootstrap`, which is a wrapper over this
// exact call, so this is the error `run` classifies.
// `run --config`: `serve` validates through this exact call before it
// reconciles, so this is the error `run` classifies.
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
@@ -1082,11 +1241,11 @@ test "a configuration whose blocklist source is in no group imports and checks c
try std.testing.expectEqual(@as(usize, 1), check_diags.warningCount());
}
test "a first start that seeds from a file prints the warnings the file earned" {
// D5, second half. The seed file is read once in the life of a database, so
// a warning it earns is printed on that start or never. `run` printed
// diagnostics only when the file was rejected, which made a successful first
// start the one place the finding could not surface.
test "a start in file mode prints the warnings the file earned" {
// D5, second half. `run` printed diagnostics only when the file was
// rejected, which made a successful start the one place a finding could not
// surface. Under file authority the file is read on every start, so this is
// the line an operator sees after every restart, not only the first.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
@@ -1119,11 +1278,10 @@ test "a first start that seeds from a file prints the warnings the file earned"
var err_writer = err_file.writer(io, &err_buf);
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface };
// The file is valid, so the start succeeds and the database is seeded.
try std.testing.expectEqual(
bootstrap.Outcome.seeded,
try seedFromFile(r, &database, tmp.dir, "config.zon"),
);
// The file is valid, so the start succeeds and the database converges onto
// it. The returned stamp is what the settings envelope reports.
const reconciled_at = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
try std.testing.expect(reconciled_at > 0);
try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
// Nothing flushes here on purpose. In production `serve` runs from this
@@ -1138,6 +1296,164 @@ test "a first start that seeds from a file prints the warnings the file earned"
try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "FAIL"));
}
test "run with a missing managed file exits 2 with the path, and never serves from the database" {
// Ruling 2: file mode fails closed. The database below is a perfectly good
// one — migrated, and the run would have reached the listeners on it in db
// mode — so a fallback would show up here as exit 0.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var data_buf: [160]u8 = undefined;
const data_dir = try std.fmt.bufPrint(&data_buf, ".zig-cache/tmp/{s}/data", .{tmp.sub_path});
var missing_buf: [160]u8 = undefined;
const missing = try std.fmt.bufPrint(&missing_buf, ".zig-cache/tmp/{s}/nope.zon", .{tmp.sub_path});
// Real buffered `File.Writer`s, not `Writer.fixed`: this asserts the
// operator received the lines, and a fixed writer's flush is a no-op that
// counts a buffered line as delivered.
var out_file = try tmp.dir.createFile(io, "stdout.txt", .{});
defer out_file.close(io);
var err_file = try tmp.dir.createFile(io, "stderr.txt", .{});
defer err_file.close(io);
var out_buf: [4096]u8 = undefined;
var err_buf: [4096]u8 = undefined;
var out_writer = out_file.writer(io, &out_buf);
var err_writer = err_file.writer(io, &err_buf);
const r: cli.Runner = .{
.io = io,
.gpa = gpa,
.out = &out_writer.interface,
.err = &err_writer.interface,
};
try std.testing.expectEqual(cli.exit_check, run(r, .{
.paths = .{ .data_dir = data_dir },
.config = missing,
}));
const printed = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192));
defer gpa.free(printed);
// The path is in the message: an error name alone tells the operator nothing
// about which file the deploy got wrong.
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, missing));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "no such file"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "nxdns check"));
// The db-mode remediation hint belongs to db mode: in file mode the operator
// has a file, and the diagnostic above says what is wrong with it.
try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "make a file the source of truth"));
}
test "reconciled_at is stamped after the commit, not from the clock the pass wrote with" {
// Ruling 7: `reconciled_at` means "this process loaded the file at T", and
// the UI compares it against the file's mtime to say whether a restart is
// pending. A stamp taken before the read makes a file written while the read
// ran look newer than the process that loaded it — a restart-pending banner
// over a configuration that is fully applied.
//
// The pass clock is pinned to 1970 here, which the engine really does use:
// the inserted client below carries it. If the two were one value, the
// returned stamp would be 1970 too.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\ .clients = .{ .{ .ip = "192.168.1.5", .name = "tablet" } },
\\}
});
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
var out_buf: [4096]u8 = undefined;
var err_buf: [1024]u8 = undefined;
var out: Writer = .fixed(&out_buf);
var err: Writer = .fixed(&err_buf);
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err };
const pass_now: i64 = 42;
const before = std.Io.Clock.real.now(io).toSeconds();
const reconciled_at = try reconcileFromFileAt(r, &database, tmp.dir, "config.zon", pass_now);
// The pinned clock reached the engine, so the two values really are separate
// inputs rather than the same read twice.
try std.testing.expectEqual(pass_now, try database.queryInt(
"SELECT first_seen FROM clients WHERE ip = '192.168.1.5'",
));
try std.testing.expect(reconciled_at != pass_now);
try std.testing.expect(reconciled_at >= before);
}
test "the startup summary reports what the reconcile changed, then that nothing changed" {
// Ruling 8: the answer to "what did that restart change" without opening
// sqlite. Also ruling 5 from the operator's side — the second start of an
// unchanged file says so.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" },
\\}
});
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
var out_file = try tmp.dir.createFile(io, "stdout.txt", .{});
defer out_file.close(io);
var out_buf: [4096]u8 = undefined;
var out_writer = out_file.writer(io, &out_buf);
var err_buf: [1024]u8 = undefined;
var err: Writer = .fixed(&err_buf);
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out_writer.interface, .err = &err };
_ = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
{
// Read back through the file: `serve` does not return for as long as the
// service runs, so a summary still in the buffer is a summary nobody
// reads.
const printed = try tmp.dir.readFileAlloc(io, "stdout.txt", gpa, .limited(8192));
defer gpa.free(printed);
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "reconciled 'config.zon':"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "upstreams +1 ~0 -0"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "settings keys changed:"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "web.password_hash"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "authentication is now enabled"));
// The keys, never the values: the hash the file set must not be echoed.
try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "$argon2id$"));
}
try tmp.dir.writeFile(io, .{ .sub_path = "stdout.txt", .data = "" });
_ = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
{
const printed = try tmp.dir.readFileAlloc(io, "stdout.txt", gpa, .limited(8192));
defer gpa.free(printed);
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "no changes"));
}
}
/// A broken stderr, in the shape `main` builds: a buffered `File.Writer`, with
/// its drain switched to the mode that fails. `Writer.fixed` cannot stand in —
/// its flush is `noopFlush`, so it has no failure to report and its `written()`
@@ -1151,8 +1467,8 @@ fn brokenErrWriter(io: std.Io, file: std.Io.File, buffer: []u8) std.Io.File.Writ
return w;
}
test "a broken error writer does not replace the reason a seed file was rejected" {
// The operator has to see why seeding failed, and a broken stderr is not
test "a broken error writer does not replace the reason a managed file was rejected" {
// The operator has to see why the start failed, and a broken stderr is not
// that reason.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
@@ -1187,7 +1503,7 @@ test "a broken error writer does not replace the reason a seed file was rejected
try std.testing.expectError(
error.MissingDefaultGroup,
seedFromFile(r, &database, tmp.dir, "config.zon"),
reconcileFromFile(r, &database, tmp.dir, "config.zon"),
);
// Empty, so the writer did fail — without this the assertion above would
@@ -1197,7 +1513,7 @@ test "a broken error writer does not replace the reason a seed file was rejected
try std.testing.expectEqual(@as(usize, 0), printed.len);
}
test "a broken error writer does not stop a first start that succeeded" {
test "a broken error writer does not stop a start whose file applied" {
// The call this file makes: a DNS server for a household does not refuse to
// resolve because stderr is broken. What it must not do is drop the warning
// on the floor, so the second half checks the buffer still holds it.
@@ -1233,10 +1549,7 @@ test "a broken error writer does not stop a first start that succeeded" {
var err_writer = brokenErrWriter(io, err_file, &err_buf);
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface };
try std.testing.expectEqual(
bootstrap.Outcome.seeded,
try seedFromFile(r, &database, tmp.dir, "config.zon"),
);
_ = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
// Nothing reached the file, so the flush really did fail.
@@ -1289,7 +1602,20 @@ fn isWildcard(addr: net.IpAddress) bool {
/// One line, at info, naming what an operator needs to see in `journalctl`
/// right after a restart: where it listens, how many upstreams it has, and
/// whether filtering is live.
fn logStartup(io: std.Io, manager: *manager_mod.Manager, upstream_count: usize, bound: Listeners) void {
///
/// Preceded by the authority (ruling 8), because every other question about a
/// restart — why a UI edit vanished, why a file edit did not apply — starts with
/// which of the two governs this process, and the journal is where an operator
/// looks for it.
fn logStartup(
io: std.Io,
authority: web_server.Authority,
manager: *manager_mod.Manager,
upstream_count: usize,
bound: Listeners,
) void {
log.info("authority: {f}", .{AuthorityText{ .authority = authority }});
var buf: [256]u8 = undefined;
var w: Writer = .fixed(&buf);
appendBind(&w, "udp", bound.udp6);
@@ -1316,6 +1642,25 @@ fn logStartup(io: std.Io, manager: *manager_mod.Manager, upstream_count: usize,
}
}
/// Which source governs this process, in the words the journal carries.
///
/// A formatter rather than a rendering into a buffer of this file's own: a
/// managed path is bounded only by `Dir.max_path_bytes`, and nested bind mounts
/// make long ones ordinary, so a fixed buffer here would silently drop exactly
/// the half of the line an operator came for. Writing straight to the log sink's
/// writer leaves the one documented, counted truncation in `platform/logging.zig`
/// as the only limit.
const AuthorityText = struct {
authority: web_server.Authority,
pub fn format(self: AuthorityText, w: *Writer) Writer.Error!void {
switch (self.authority) {
.database => try w.writeAll("database"),
.managed_file => |path| try w.print("file ({s})", .{path}),
}
}
};
/// Silent on overflow: a truncated startup line is not worth a failure path,
/// and 256 bytes hold four addresses.
fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void {
@@ -1323,6 +1668,34 @@ fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void {
w.print(" {s} {f}", .{ which, value }) catch {};
}
test "the startup line names which source governs this process, path and all" {
// Ruling 8. Every other question about a restart starts here, so the answer
// is in the journal rather than derived from the unit file by whoever is
// reading at 2am.
const gpa = std.testing.allocator;
var short: Writer.Allocating = .init(gpa);
defer short.deinit();
try short.writer.print("{f}", .{AuthorityText{ .authority = .database }});
try std.testing.expectEqualStrings("database", short.written());
var named: Writer.Allocating = .init(gpa);
defer named.deinit();
try named.writer.print("{f}", .{AuthorityText{ .authority = .{ .managed_file = "/etc/nxdns/config.zon" } }});
try std.testing.expectEqualStrings("file (/etc/nxdns/config.zon)", named.written());
// A path past any buffer this file could reasonably have picked. Nested bind
// mounts produce paths like this, and the path is the half of the line the
// operator came for — dropping it to keep the line short is the wrong trade.
const long_path = "/mnt/" ++ ("deeply-nested-mount/" ** 20) ++ "config.zon";
try std.testing.expect(long_path.len > 256);
var long: Writer.Allocating = .init(gpa);
defer long.deinit();
try long.writer.print("{f}", .{AuthorityText{ .authority = .{ .managed_file = long_path } }});
try std.testing.expect(std.mem.containsAtLeast(u8, long.written(), 1, long_path));
}
const test_address = @import("platform/address.zig");
test "one maintenance pass drops the api limiter's stale buckets" {
+165 -106
View File
@@ -22,6 +22,7 @@ const app = @import("app.zig");
const config_export = @import("config/export.zig");
const faults = @import("config/faults.zig");
const import = @import("config/import.zig");
const loader = @import("config/loader.zig");
const model = @import("config/model.zig");
const validate = @import("config/validate.zig");
const cert_store = @import("server/cert_store.zig");
@@ -50,19 +51,31 @@ pub const querylog_db_name = "querylog.db";
pub const Paths = struct {
/// PLAN §3.13.
data_dir: []const u8 = "/var/lib/nxdns",
config: []const u8 = "/etc/nxdns/config.zon",
};
/// `config_explicit` records whether `--config` was given, because `check` has
/// to tell "the operator named a file" from "the default path happens to
/// exist".
pub const CheckArgs = struct { paths: Paths = .{}, config_explicit: bool = false };
/// Milestone-20 ruling 1: `--config` has no default path, and its presence is
/// the whole of the authority decision. Null means the database is authority —
/// for `run` that is today's appliance behaviour, for `check` it is the database
/// that gets graded. A file sitting at a well-known path that no flag names
/// changes nothing.
pub const CheckArgs = struct { paths: Paths = .{}, config: ?[]const u8 = null };
pub const ExportArgs = struct { paths: Paths = .{}, out: ?[]const u8 = null };
pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, force: bool = false };
/// `allow_delete` is `--allow-delete`: it permits an import whose diff removes
/// declarative rows (ruling 6).
pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, allow_delete: bool = false };
/// `config` names the managed configuration file and, by being present at all,
/// makes that file the sole declarative source of truth: it is read, validated
/// and reconciled into the database on every start.
///
/// `web_dev` is milestone-8 ruling 24's `--web-dev <dir>`: serve the web
/// interface from that directory instead of the embedded assets.
pub const RunArgs = struct { paths: Paths = .{}, web_dev: ?[]const u8 = null };
pub const RunArgs = struct {
paths: Paths = .{},
config: ?[]const u8 = null,
web_dev: ?[]const u8 = null,
};
pub const Command = union(enum) {
run: RunArgs,
@@ -176,7 +189,7 @@ fn parseRunArgs(argv: []const []const u8) ParseError!RunArgs {
if (eql(flag.name, "data-dir")) {
args.paths.data_dir = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "config")) {
args.paths.config = try flagValue(flag, argv, &i);
args.config = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "web-dev")) {
args.web_dev = try flagValue(flag, argv, &i);
} else return error.UnknownFlag;
@@ -192,8 +205,7 @@ fn parseCheckArgs(argv: []const []const u8) ParseError!CheckArgs {
if (eql(flag.name, "data-dir")) {
args.paths.data_dir = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "config")) {
args.paths.config = try flagValue(flag, argv, &i);
args.config_explicit = true;
args.config = try flagValue(flag, argv, &i);
} else return error.UnknownFlag;
}
return args;
@@ -215,7 +227,7 @@ fn parseExportArgs(argv: []const []const u8) ParseError!ExportArgs {
fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs {
var paths: Paths = .{};
var force = false;
var allow_delete = false;
var file: ?[]const u8 = null;
var i: usize = 0;
@@ -227,18 +239,18 @@ fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs {
};
if (eql(flag.name, "data-dir")) {
paths.data_dir = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "force")) {
// A boolean flag takes no value, so `--force=1` is not a spelling of
// any flag this program has.
} else if (eql(flag.name, "allow-delete")) {
// A boolean flag takes no value, so `--allow-delete=1` is not a
// spelling of any flag this program has.
if (flag.attached != null) return error.UnknownFlag;
force = true;
allow_delete = true;
} else return error.UnknownFlag;
}
return .{
.paths = paths,
.file = file orelse return error.MissingArgument,
.force = force,
.allow_delete = allow_delete,
};
}
@@ -379,14 +391,30 @@ const usage_text =
\\
\\options:
\\ --data-dir DIR data directory (default /var/lib/nxdns)
\\ --config FILE configuration file (default /etc/nxdns/config.zon)
\\ --config FILE run: make FILE the sole source of configuration and
\\ reconcile the database onto it at every start;
\\ check: grade FILE instead of the database
\\ --out FILE write the export to FILE instead of stdout
\\ --force let import replace a database that already has content
\\ --allow-delete let import apply a file whose diff deletes rows
\\ --web-dev DIR run only: serve the web interface from DIR instead of
\\ the embedded assets
\\
;
/// The one remediation line for a database that holds no usable configuration.
/// Both routes to that state print it: `run` refusing an unconfigured database
/// (`NoUsableUpstreams`) and `check` finding no `config.db` at all.
///
/// It lives here, beside the exit-code mapping, because a Zig error carries no
/// text and `config/validate.zig` must stay blind to which source it is grading
/// — the same file validates a database reading and a managed file.
pub const db_source_hint =
"load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`\n";
pub fn writeDbSourceHint(w: *Writer) void {
w.writeAll(db_source_hint) catch {};
}
/// Returns nothing, so a writer failure here has nowhere to go. Every caller
/// flushes afterwards and reports that failure instead.
pub fn usage(w: *Writer) void {
@@ -493,7 +521,7 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
&database,
std.Io.Dir.cwd(),
args.file,
.{ .force = args.force },
.{ .allow_delete = args.allow_delete },
diags,
);
@@ -514,9 +542,10 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
/// That file is the only list — this function keeps none of its own, which is
/// what stops `run`, `check` and `import` drifting apart again (D1).
///
/// `error.DatabaseNotEmpty` is the one exception, and it is deliberate: it
/// reports the state of the database rather than the content of a file, so it
/// is not a configuration fault, and `import` alone decides it is exit 2.
/// `error.DestructiveImport` is the one exception, and it is deliberate: it
/// reports what the diff would do to the database rather than the content of a
/// file, so it is not a configuration fault, and `import` alone decides it is
/// exit 2.
///
/// `error.OutOfMemory` is matched first, before anything else is consulted.
/// Both recording paths — `validate` and import's per-line rendering of a ZON
@@ -530,7 +559,7 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
fn failureExitCode(e: anyerror, failures: usize) u8 {
if (e == error.OutOfMemory) return exit_runtime;
if (failures != 0) return exit_check;
if (e == error.DatabaseNotEmpty) return exit_check;
if (e == error.DestructiveImport) return exit_check;
return if (faults.isConfigFault(e)) exit_check else exit_runtime;
}
@@ -563,28 +592,31 @@ fn checkImpl(r: Runner, args: CheckArgs, probe: bool) !u8 {
// Which source was used is printed in every branch, so the answer is never
// ambiguous about what it checked.
if (args.config_explicit) {
try r.out.print("checking configuration file {s}\n", .{args.paths.config});
return checkFile(r, arena, args.paths.config, probe);
//
// Ruling 1: the invocation decides, and nothing else. The heuristic that
// used to live here — probe for `config.db`, fall back to probing
// `/etc/nxdns/config.zon`, grade whichever exists — made the answer a
// function of what happened to be on disk, which is the ambient inference
// that made seed-once bootstrap a source of documentation lies. A file no
// flag names is not graded.
if (args.config) |path| {
try r.out.print("checking configuration file {s}\n", .{path});
return checkFile(r, arena, path, probe);
}
const config_db_path = try std.fs.path.joinZ(arena, &.{ args.paths.data_dir, config_db_name });
if (try pathExists(r.io, config_db_path)) {
try r.out.print("checking database {s}\n", .{config_db_path});
return checkDatabase(r, arena, config_db_path, probe);
if (!try pathExists(r.io, config_db_path)) {
// The deleted heuristic's "nothing to check" branch, replaced rather
// than dropped: an operator running `check` on a box that has never
// been configured gets the same exit code and one line saying what to
// do about it.
try r.out.print("no config database at {s}\n", .{config_db_path});
try r.out.writeAll(db_source_hint);
return exit_check;
}
if (try pathExists(r.io, args.paths.config)) {
try r.out.print("checking configuration file {s}\n", .{args.paths.config});
return checkFile(r, arena, args.paths.config, probe);
}
try r.out.print("nothing to check: no {s} in {s} and no {s}\n", .{
config_db_name,
args.paths.data_dir,
args.paths.config,
});
return exit_check;
try r.out.print("checking database {s}\n", .{config_db_path});
return checkDatabase(r, arena, config_db_path, probe);
}
/// `check` reads `config.db` and writes nothing to it (F-c): no create, no
@@ -722,52 +754,31 @@ fn pathReadable(io: std.Io, path: []const u8) std.Io.Dir.AccessError!bool {
return true;
}
/// Grades the file `--config` named, through `config/loader.zig` — the same
/// read and the same classification `nxdns run --config` uses. That shared
/// helper is what makes the scoped agreement of ruling 2 true: `check` reaches
/// exactly the read, size and parse faults `run` would reach, and validation
/// below is the same call on the same `Config`.
///
/// D4: a named file that is missing or unreadable is the same operator-fixable
/// condition as one that fails to parse, so it is reported as a finding rather
/// than escaping as a runtime failure.
fn checkFile(r: Runner, arena: Allocator, path: []const u8, probe: bool) !u8 {
const source = std.Io.Dir.cwd().readFileAllocOptions(
r.io,
path,
arena,
.limited(import.max_config_bytes),
.of(u8),
0,
) catch |e| switch (e) {
error.StreamTooLong => {
try r.out.print("FAIL {s}: larger than {d} bytes\n", .{ path, import.max_config_bytes });
return exit_check;
},
// D4: a named file that is missing or unreadable is the same
// operator-fixable condition as one that fails to parse, so it is
// reported as a finding rather than escaping as a runtime failure. The
// implicit path already exits 2 when it finds nothing to check; naming
// the file must not change the code.
error.FileNotFound => {
try r.out.print("FAIL {s}: no such file\n", .{path});
return exit_check;
},
error.AccessDenied, error.PermissionDenied => {
try r.out.print("FAIL {s}: not readable\n", .{path});
return exit_check;
},
else => |other| return other,
};
var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit();
// Arena-owned and never handed to `std.zon.parse.free`; see the rule and its
// `parse.zig:874` citation in `config/import.zig`.
var zon_diag: std.zon.parse.Diagnostics = .{};
const cfg = std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) {
const cfg = loader.load(r.io, arena, std.Io.Dir.cwd(), path, &diags) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
// The rendering carries the line and column, which is the whole value of
// running `check` against a file the operator just edited. It is
// multi-line, and `check` promises one line per problem, so it goes
// through the same `Diagnostics` channel `nxdns import` uses rather than
// into one `FAIL` record with newlines inside it.
error.ParseZon => {
var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit();
try import.reportParseFailure(&diags, &zon_diag);
error.ManagedConfigUnreadable, error.ConfigTooLarge, error.ParseZon => {
// One line per problem, the promise the rest of `check` keeps: a
// multi-line ZON rendering is several problems, not one `FAIL`
// record with newlines inside it.
try diags.writeAll(r.out);
return exit_check;
},
// A box fault — fd exhaustion, an I/O error — is not a verdict on the
// configuration and keeps its own name at exit 1.
else => |other| return other,
};
return checkConfig(r, cfg, probe);
@@ -1013,17 +1024,22 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
const testing = std.testing;
test "parseArgs accepts run with no flags" {
test "run without --config selects database authority" {
// Ruling 1: authority is the invocation. No default path, so nothing on
// disk can make a bare `run` read a file.
const command = try parseArgs(&.{"run"});
try testing.expectEqualStrings("/var/lib/nxdns", command.run.paths.data_dir);
try testing.expectEqualStrings("/etc/nxdns/config.zon", command.run.paths.config);
try testing.expectEqual(@as(?[]const u8, null), command.run.config);
try testing.expectEqual(@as(?[]const u8, null), command.run.web_dev);
}
test "parseArgs accepts run with --data-dir and --config" {
test "run --config selects file authority and the path lands in the run args" {
const attached = try parseArgs(&.{ "run", "--config=/etc/nxdns/config.zon" });
try testing.expectEqualStrings("/etc/nxdns/config.zon", attached.run.config.?);
const command = try parseArgs(&.{ "run", "--data-dir", "/srv/nx", "--config", "/tmp/c.zon" });
try testing.expectEqualStrings("/srv/nx", command.run.paths.data_dir);
try testing.expectEqualStrings("/tmp/c.zon", command.run.paths.config);
try testing.expectEqualStrings("/tmp/c.zon", command.run.config.?);
}
test "parseArgs accepts run with --web-dev in both spellings" {
@@ -1049,13 +1065,12 @@ test "parseArgs accepts --data-dir with and without an equals sign" {
try testing.expectEqualStrings("/srv/nx", separate.check.paths.data_dir);
}
test "parseArgs records whether check was given an explicit --config" {
test "bare check grades the database and check --config grades the file" {
const implicit = try parseArgs(&.{"check"});
try testing.expect(!implicit.check.config_explicit);
try testing.expectEqual(@as(?[]const u8, null), implicit.check.config);
const explicit = try parseArgs(&.{ "check", "--config=/tmp/c.zon" });
try testing.expect(explicit.check.config_explicit);
try testing.expectEqualStrings("/tmp/c.zon", explicit.check.paths.config);
try testing.expectEqualStrings("/tmp/c.zon", explicit.check.config.?);
}
test "parseArgs accepts export with --out" {
@@ -1066,17 +1081,21 @@ test "parseArgs accepts export with --out" {
try testing.expectEqual(@as(?[]const u8, null), bare.export_.out);
}
test "parseArgs accepts import with a file, --force and --data-dir" {
const command = try parseArgs(&.{ "import", "c.zon", "--force", "--data-dir=/srv/nx" });
test "parseArgs accepts import with a file, --allow-delete and --data-dir" {
const command = try parseArgs(&.{ "import", "c.zon", "--allow-delete", "--data-dir=/srv/nx" });
try testing.expectEqualStrings("c.zon", command.import_.file);
try testing.expect(command.import_.force);
try testing.expect(command.import_.allow_delete);
try testing.expectEqualStrings("/srv/nx", command.import_.paths.data_dir);
}
test "parseArgs accepts import with the file after the flags" {
const command = try parseArgs(&.{ "import", "--data-dir", "/srv/nx", "c.zon" });
try testing.expectEqualStrings("c.zon", command.import_.file);
try testing.expect(!command.import_.force);
try testing.expect(!command.import_.allow_delete);
}
test "the renamed import flag replaces --force rather than joining it" {
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--force" }));
}
test "parseArgs accepts version" {
@@ -1091,7 +1110,7 @@ test "parseArgs accepts help, --help and -h" {
test "parseArgs rejects import without a file" {
try testing.expectError(error.MissingArgument, parseArgs(&.{"import"}));
try testing.expectError(error.MissingArgument, parseArgs(&.{ "import", "--force" }));
try testing.expectError(error.MissingArgument, parseArgs(&.{ "import", "--allow-delete" }));
}
test "parseArgs rejects --out without a value" {
@@ -1101,7 +1120,7 @@ test "parseArgs rejects --out without a value" {
test "parseArgs rejects an unknown flag" {
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "check", "--nope" }));
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--force=1" }));
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--allow-delete=1" }));
}
test "parseArgs rejects an unknown command" {
@@ -1134,6 +1153,17 @@ test "usage_text lists every command in command_names" {
}
}
test "usage_text names the flags this milestone renamed and describes --config" {
// The flag an operator reaches for is the one the help text names. `--force`
// is gone rather than aliased (greenfield rules), and `--config` no longer
// advertises a default path, because there is none: its presence is the
// whole authority decision.
try testing.expect(std.mem.containsAtLeast(u8, usage_text, 1, " --allow-delete "));
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, usage_text, "--force"));
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, usage_text, "default /etc/nxdns/config.zon"));
try testing.expect(std.mem.containsAtLeast(u8, usage_text, 1, "sole source of configuration"));
}
test "usage writes non-empty text" {
var out: Writer.Allocating = .init(testing.allocator);
defer out.deinit();
@@ -1246,7 +1276,7 @@ test "runUsageError names the fault and prints the usage text" {
}
test "failureExitCode separates a fixable configuration from a runtime failure" {
try testing.expectEqual(exit_check, failureExitCode(error.DatabaseNotEmpty, 0));
try testing.expectEqual(exit_check, failureExitCode(error.DestructiveImport, 0));
try testing.expectEqual(exit_check, failureExitCode(error.ParseZon, 0));
try testing.expectEqual(exit_check, failureExitCode(error.NoUpstreams, 1));
try testing.expectEqual(exit_runtime, failureExitCode(error.IoErr, 0));
@@ -1302,9 +1332,13 @@ test "failureExitCode keeps no list of its own and classifies through config/fau
}
// The one config-shaped exit 2 `cli` still decides for itself: it reports
// the state of the database, not the content of a file.
try testing.expect(!faults.isConfigFault(error.DatabaseNotEmpty));
try testing.expectEqual(exit_check, failureExitCode(error.DatabaseNotEmpty, 0));
// what the diff would do to the database, not the content of a file.
try testing.expect(!faults.isConfigFault(error.DestructiveImport));
try testing.expectEqual(exit_check, failureExitCode(error.DestructiveImport, 0));
// The managed file goes the other way: `config/loader.zig` converts the
// path class, so the classification — not this function — carries it.
try testing.expectEqual(exit_check, failureExitCode(error.ManagedConfigUnreadable, 0));
}
const fixtures = @import("test_fixtures");
@@ -1457,10 +1491,7 @@ test "check --config naming a missing file is a reported failure at exit 2" {
defer captured.deinit();
const r = captured.runner();
const code = runCheck(r, .{
.paths = .{ .config = env.missing_path },
.config_explicit = true,
}, false);
const code = runCheck(r, .{ .config = env.missing_path }, false);
try testing.expectEqual(exit_check, code);
const text = captured.out.written();
@@ -1469,6 +1500,37 @@ test "check --config naming a missing file is a reported failure at exit 2" {
try testing.expectEqualStrings("", captured.err.written());
}
test "bare check with no config database exits 2 and says how to make one" {
// The deleted heuristic's "nothing to check" branch, replaced. The valid
// file sitting in the same directory is the other half of ruling 1: bare
// `check` grades the database, and a file no flag named is not consulted —
// if it were, this run would print "OK: no problems found" instead.
var env: CheckEnv = undefined;
try env.init();
defer env.deinit();
var captured: Captured = .init(testing.allocator);
defer captured.deinit();
const r = captured.runner();
try env.tmp.dir.writeFile(r.io, .{ .sub_path = "config.zon", .data =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\}
});
const code = runCheck(r, .{ .paths = .{ .data_dir = env.data_dir } }, false);
try testing.expectEqual(exit_check, code);
const text = captured.out.written();
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "no config database at "));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, config_db_name));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, db_source_hint));
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, text, "OK"));
try testing.expectEqualStrings("", captured.err.written());
}
test "check renders a multi-line ZON failure as one FAIL line per message" {
// The rendering used to go inline into a single `FAIL` record, which put
// newlines mid-line and broke the one-line-per-problem promise the rest of
@@ -1486,10 +1548,7 @@ test "check renders a multi-line ZON failure as one FAIL line per message" {
var path_buf: [160]u8 = undefined;
const config_path = try env.path(&path_buf, "config.zon");
const code = runCheck(r, .{
.paths = .{ .config = config_path },
.config_explicit = true,
}, false);
const code = runCheck(r, .{ .config = config_path }, false);
try testing.expectEqual(exit_check, code);
const text = captured.out.written();
-141
View File
@@ -1,141 +0,0 @@
//! First-start seeding (PLAN §3.5).
//!
//! A policy wrapper over `import.importFile`, and nothing more. There is exactly
//! one code path from a config file into the database, so bootstrap and
//! `nxdns import` cannot drift apart.
//!
//! The policy is three lines long:
//!
//! - no file → normal steady state, keep the database as it is;
//! - database already configured → the file is ignored, as PLAN §3.5 requires.
//! Configured means an operator put something there. A database that has only
//! answered queries is not configured, however many client rows the DNS path
//! materialised into it, and `import.isEmpty` is where that line is drawn;
//! - otherwise → import it, and a file that is unreadable, unparseable or
//! invalid is an error. The operator wrote that file and meant it; starting
//! with silent defaults instead is the exact failure mode PLAN §1.3 exists to
//! prevent.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../storage/db.zig");
const import = @import("import.zig");
const validate = @import("validate.zig");
const log = std.log.scoped(.config_bootstrap);
pub const Outcome = enum { seeded, db_already_configured, no_config_file };
pub const Error = import.Error || std.Io.Dir.AccessError;
/// Called by `nxdns run` before serving.
pub fn bootstrap(
io: std.Io,
gpa: Allocator,
database: *db.Db,
dir: std.Io.Dir,
config_path: []const u8,
diags: *validate.Diagnostics,
) Error!Outcome {
dir.access(io, config_path, .{}) catch |e| switch (e) {
error.FileNotFound => {
log.info("no configuration file at '{s}'; using the database as it is", .{config_path});
return .no_config_file;
},
else => |other| return other,
};
// Deliberately before the read: on every start after the first, the file is
// not even opened.
if (!try import.isEmpty(database)) {
log.info("configuration file ignored; the database is already configured", .{});
return .db_already_configured;
}
try import.importFile(io, gpa, database, dir, config_path, .{ .force = false }, diags);
log.info("seeded the database from '{s}'", .{config_path});
return .seeded;
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
//
// All three outcomes are exercised end to end in
// `src/storage/storage_integration_test.zig` (S7) against a real data directory.
// What the two cases below add is the one distinction that decides which outcome
// an operator gets, and it is too important to leave behind a `-Dintegration`
// flag: whether the database has been *configured*, not whether it has been
// *used*.
const testing = std.testing;
const clients_repo = @import("../storage/repositories/clients_repo.zig");
const migrations = @import("../storage/migrations.zig");
const seed_source =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\}
;
/// Unparseable on purpose: a call that succeeds proves the file was never read.
const broken_source = ".{ .groups = ";
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
test "a server that has answered queries still seeds from its configuration file" {
const io = testing.io;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = seed_source });
var database = try openMigrated();
defer database.close();
// The unattended first boot: the server came up on defaults, answered
// traffic, and the operator dropped a config file in afterwards.
try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
const outcome = try bootstrap(io, testing.allocator, &database, tmp.dir, "config.zon", &diags);
try testing.expectEqual(Outcome.seeded, outcome);
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
// Seeding did not cost the operator the device list they had been watching.
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
}
test "a client the operator has customised keeps the configuration file out" {
const io = testing.io;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = broken_source });
var database = try openMigrated();
defer database.close();
try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);
const id = try database.queryInt("SELECT id FROM clients WHERE ip = '192.168.1.5'");
try clients_repo.updateClient(&database, id, .{ .name = "tv", .group_id = 1 });
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
const outcome = try bootstrap(io, testing.allocator, &database, tmp.dir, "config.zon", &diags);
try testing.expectEqual(Outcome.db_already_configured, outcome);
try testing.expectEqual(@as(usize, 0), diags.problems.items.len);
// The name and the flag the operator set are still theirs.
try testing.expectEqual(@as(i64, 1), try database.queryInt(
"SELECT count(*) FROM clients WHERE name = 'tv' AND hand_edited = 1",
));
}
+57 -10
View File
@@ -34,7 +34,7 @@ pub const Error = ReadError || Writer.Error ||
const header =
\\// nxdns configuration
\\// generated by `nxdns export` the database is the source of truth
\\// generated by `nxdns export` from the running configuration
\\
;
@@ -64,11 +64,13 @@ pub fn readConfig(database: *db.Db, arena: Allocator) ReadError!model.Config {
cfg.local_records = (try local_repo.listLocalRecords(database, arena)).items;
cfg.forward_zones = (try local_repo.listForwardZones(database, arena)).items;
// `web.password` is operator input and is never stored; the exported file
// always carries an empty one. This is exactly what makes the round trip
// stable: re-importing takes the "password is empty" branch and stores the
// same hash.
cfg.web.password = "";
// `web.password` is operator input and is never stored, so the exported
// file always states it as absent. Absent rather than `""`: a present empty
// password is refused by `validate` (ruling 4), so exporting one would make
// every export fail its own rules. It is also what makes the round trip
// stable — re-applying the file takes the "password_hash written verbatim"
// branch and stores the same hash.
cfg.web.password = null;
return cfg;
}
@@ -250,7 +252,7 @@ test "readConfig, writeConfig, import and readConfig again produce an equal conf
try testing.expectEqual(a.dns.port, b.dns.port);
try testing.expectEqual(a.logging.level, b.logging.level);
try testing.expectEqualStrings(a.web.password_hash, b.web.password_hash);
try testing.expectEqualStrings(a.web.password_hash.?, b.web.password_hash.?);
try testing.expectEqual(a.groups.len, b.groups.len);
try testing.expectEqual(a.upstreams.len, b.upstreams.len);
for (a.upstreams, b.upstreams) |left, right| {
@@ -303,12 +305,57 @@ test "an exported password_hash survives a re-import unchanged" {
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
.web = .{ .password = "correct horse battery staple" },
};
try import.applyToDb(io, gpa, &database, cfg, 42, .{});
var diags: validate.Diagnostics = .init(gpa);
defer diags.deinit();
try import.apply(io, gpa, &database, cfg, 42, .{}, &diags);
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const exported = try readConfig(&database, arena_state.allocator());
try testing.expectEqualStrings("", exported.web.password);
try testing.expect(std.mem.startsWith(u8, exported.web.password_hash, "$argon2id$"));
try testing.expectEqual(@as(?[]const u8, null), exported.web.password);
try testing.expect(std.mem.startsWith(u8, exported.web.password_hash.?, "$argon2id$"));
}
test "the exported password form is the one validate accepts" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = testing.allocator;
var database = try openMigrated();
defer database.close();
var apply_diags: validate.Diagnostics = .init(gpa);
defer apply_diags.deinit();
try import.apply(io, gpa, &database, .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
.web = .{ .password = "correct horse battery staple" },
}, 42, .{}, &apply_diags);
var out: Writer.Allocating = .init(gpa);
defer out.deinit();
try writeToWriter(gpa, &database, &out.writer);
// The literal form matters: an export carrying `password = ""` beside a
// stored hash would trip `EmptyWebPassword` on the way back in, so export
// would produce a file its own validator refuses.
try testing.expect(std.mem.indexOf(u8, out.written(), ".password = null,") != null);
const source = try gpa.dupeZ(u8, out.written());
defer gpa.free(source);
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const reparsed = try std.zon.parse.fromSliceAlloc(
model.Config,
arena_state.allocator(),
source,
null,
.{},
);
var diags: validate.Diagnostics = .init(gpa);
defer diags.deinit();
try validate.validate(reparsed, &diags);
try testing.expectEqual(@as(?[]const u8, null), reparsed.web.password);
try testing.expect(std.mem.startsWith(u8, reparsed.web.password_hash.?, "$argon2id$"));
}
+27 -10
View File
@@ -13,17 +13,26 @@ const validate = @import("validate.zig");
/// `ValidateError` enters as a whole set rather than variant by variant, so a
/// variant added to the validator cannot silently fall through to exit 1. The
/// four extras are the configuration faults raised outside the validator: the
/// ZON reader (`ParseZon`), the seed-file size limit (`ConfigTooLarge`), the
/// composition root's upstream build (`NoUsableUpstreams`) and its certificate
/// load (`BadCertificate`).
/// five extras are the configuration faults raised outside the validator: the
/// ZON reader (`ParseZon`), the file size limit (`ConfigTooLarge`), the managed
/// file the operator named and this process cannot open
/// (`ManagedConfigUnreadable`, milestone-20 ruling 2), the composition root's
/// upstream build (`NoUsableUpstreams`) and its certificate load
/// (`BadCertificate`).
///
/// Not here on purpose: `error.DatabaseNotEmpty`, which reports the state of
/// the database rather than the content of a file, and is the one config-shaped
/// exit 2 `cli` decides for itself.
/// `ManagedConfigUnreadable` is the only place a missing or unreadable path is a
/// configuration fault, and it is deliberately not `FileNotFound` itself: the
/// operator named that path on the command line, so it is theirs to fix, while a
/// missing file anywhere else stays a runtime failure. `config/loader.zig` owns
/// the conversion and the closed set of open errors that qualify.
///
/// Not here on purpose: `error.DestructiveImport`, which reports what an import
/// would do to the database rather than the content of a file, and is the one
/// config-shaped exit 2 `cli` decides for itself.
const ConfigFault = validate.ValidateError || error{
ParseZon,
ConfigTooLarge,
ManagedConfigUnreadable,
NoUsableUpstreams,
BadCertificate,
};
@@ -94,6 +103,14 @@ test "the faults raised outside the validator are configuration faults" {
try testing.expect(isConfigFault(error.BadCertificate));
}
test "a managed file the operator named and this process cannot open is exit 2" {
// Ruling 2. The general rule below still holds — a bare `FileNotFound` is a
// runtime failure — and this is the one converted form, produced only by
// `config/loader.zig` for a path `--config` named.
try testing.expect(isConfigFault(error.ManagedConfigUnreadable));
try testing.expect(!isConfigFault(error.FileNotFound));
}
test "the seed-file errors that used to exit 1 from run are configuration faults" {
// D1 verbatim: these three reached `run` from a rejected seed file and were
// classified as runtime failures.
@@ -107,9 +124,9 @@ test "a runtime failure is not a configuration fault" {
try testing.expect(!isConfigFault(error.AccessDenied));
try testing.expect(!isConfigFault(error.FileNotFound));
try testing.expect(!isConfigFault(error.AddressInUse));
// A state conflict, not a bad file: `import` refuses to overwrite a
// configured database and decides that exit code itself.
try testing.expect(!isConfigFault(error.DatabaseNotEmpty));
// A verdict on the diff, not on the file: `import` refuses a run that would
// delete rows and decides that exit code itself.
try testing.expect(!isConfigFault(error.DestructiveImport));
// Only ever a warning, so it never reaches an exit code by this route.
try testing.expect(!isConfigFault(error.SourceInNoGroup));
}
+268 -675
View File
File diff suppressed because it is too large Load Diff
+323
View File
@@ -0,0 +1,323 @@
//! The one way a configuration file becomes a `model.Config`.
//!
//! `nxdns run --config <file>` and `nxdns check --config <file>` must grade the
//! same file the same way, so the read, the error classification and the parse
//! live here rather than once per subcommand. `config/faults.zig` exists for the
//! same reason one layer up: two copies of a rule are two rules.
//!
//! **The classification.** `faults.isConfigFault` deliberately excludes
//! `FileNotFound` and `AccessDenied` in general — a missing file is usually a
//! broken box, not a wrong configuration. The managed file is the one place
//! where the opposite holds: the operator named that path, so a path that does
//! not resolve is a configuration fault (exit 2, `nxdns check` is the next
//! step). Only the path class converts:
//!
//! `FileNotFound`, `AccessDenied`, `PermissionDenied`, `NotDir`, `IsDir`,
//! `SymLinkLoop`, `NameTooLong`, `BadPathName` → `ManagedConfigUnreadable`
//!
//! Everything else `readFileAllocOptions` can return — `SystemResources`, the
//! two fd-quota errors, I/O failures, `OutOfMemory` — propagates unmapped and
//! exits 1. Those are box faults a retry can clear, and the shipped unit carries
//! `RestartPreventExitStatus=2 64`: mapping a transient failure to exit 2 would
//! stop the service permanently on a fault that would have cleared itself.
//!
//! The mapping is a named error set switched exhaustively with
//! `else => |other| return other`, so an error a Zig upgrade adds to
//! `ReadFileAllocError` defaults to exit 1 rather than silently to exit 2.
const std = @import("std");
const Allocator = std.mem.Allocator;
const model = @import("model.zig");
const validate = @import("validate.zig");
/// The ceiling on a configuration file. A file above it is a configuration
/// fault, not a resource failure: nothing an operator writes by hand comes near
/// 4 MiB, so this is a typo or a wrong path rather than a real config.
pub const max_config_bytes = 4 * 1024 * 1024;
/// Every error `readFileAllocOptions` can hand back, plus the parse.
pub const ReadError = std.Io.Dir.ReadFileAllocError;
/// The open failures that mean the operator's path is wrong rather than the box
/// being broken. Spelled out as a set rather than as switch prongs so that the
/// list is one thing a reader can find and a test can enumerate.
pub const PathFault = error{
FileNotFound,
AccessDenied,
PermissionDenied,
NotDir,
IsDir,
SymLinkLoop,
NameTooLong,
BadPathName,
};
pub const Error = ReadError || error{ ManagedConfigUnreadable, ConfigTooLarge, ParseZon };
/// The classification itself, pure and testable on its own: a path-class
/// failure becomes `ManagedConfigUnreadable`, the size limit becomes
/// `ConfigTooLarge`, and every other member travels unchanged.
pub fn mapReadError(e: ReadError) Error {
return switch (e) {
error.StreamTooLong => error.ConfigTooLarge,
error.FileNotFound,
error.AccessDenied,
error.PermissionDenied,
error.NotDir,
error.IsDir,
error.SymLinkLoop,
error.NameTooLong,
error.BadPathName,
=> error.ManagedConfigUnreadable,
else => |other| other,
};
}
/// The file, NUL-terminated because `std.zon.parse` needs a sentinel and
/// `readFileAlloc` cannot supply one. Errors travel exactly as the filesystem
/// returned them: `nxdns import` reads an operator-supplied argument, not a
/// managed file, and its exit codes are its own.
pub fn readSource(
io: std.Io,
gpa: Allocator,
dir: std.Io.Dir,
path: []const u8,
) ReadError![:0]u8 {
return dir.readFileAllocOptions(io, path, gpa, .limited(max_config_bytes), .of(u8), 0);
}
/// `readSource` under the managed-file classification, with the reason recorded
/// as a diagnostic. A Zig error carries no text, so the path an operator has to
/// go and fix reaches them through `Diagnostics` — the same channel every other
/// configuration problem travels down, and the reason `check` and `run` print
/// these in one shape.
pub fn readManaged(
io: std.Io,
gpa: Allocator,
dir: std.Io.Dir,
path: []const u8,
diags: *validate.Diagnostics,
) Error![:0]u8 {
return readSource(io, gpa, dir, path) catch |e| {
switch (e) {
error.StreamTooLong => try diags.add(
error.ConfigTooLarge,
"{s}",
.{path},
"larger than {d} bytes",
.{max_config_bytes},
),
error.FileNotFound => try diags.add(
error.ManagedConfigUnreadable,
"{s}",
.{path},
"no such file",
.{},
),
error.AccessDenied, error.PermissionDenied => try diags.add(
error.ManagedConfigUnreadable,
"{s}",
.{path},
"not readable",
.{},
),
error.IsDir => try diags.add(
error.ManagedConfigUnreadable,
"{s}",
.{path},
"is a directory, not a configuration file",
.{},
),
error.NotDir, error.SymLinkLoop, error.NameTooLong, error.BadPathName => try diags.add(
error.ManagedConfigUnreadable,
"{s}",
.{path},
"cannot be opened ({s})",
.{@errorName(e)},
),
// A box fault. It exits 1 with its own name and records nothing: a
// diagnostic would file it under "the configuration is wrong".
else => {},
}
return mapReadError(e);
};
}
/// The line and column of a ZON syntax error are the only thing the operator can
/// act on, so they travel the same channel as every other config problem: the
/// caller's `Diagnostics`, which `run`, `check` and `import` all render. The
/// global log is not that channel — an operator reading command output would see
/// a bare `ParseZon` and nothing else.
///
/// `std.zon.parse.Diagnostics` renders one "line:column: error: text" line per
/// problem, plus a "note:" line each, so each rendered line becomes one
/// `Problem` and the list keeps the parser's order. Rendering them inline would
/// put newlines inside a single `FAIL` record.
pub fn reportParseFailure(
diags: *validate.Diagnostics,
zon_diag: *const std.zon.parse.Diagnostics,
) error{OutOfMemory}!void {
const rendered = try std.fmt.allocPrint(diags.gpa, "{f}", .{zon_diag});
defer diags.gpa.free(rendered);
var lines = std.mem.splitScalar(u8, rendered, '\n');
while (lines.next()) |line| {
if (line.len == 0) continue;
try diags.add(error.ParseZon, "config", .{}, "{s}", .{line});
}
}
/// The parse, with its failure rendered. The result is arena-owned and
/// `std.zon.parse.free` is NEVER called on it: `Parser.parseStruct` fills an
/// absent field by copying the struct's default straight through
/// (parse.zig:874), so a defaulted `[]const u8` — and this model has many
/// non-empty string defaults — points into the binary's read-only data.
/// `parse.free` keeps no record of which fields were parsed and which were
/// defaulted, so it would `@memset` and free rodata. Freeing the arena is the
/// only correct release.
pub fn parse(
arena: Allocator,
source: [:0]const u8,
diags: *validate.Diagnostics,
) error{ ParseZon, OutOfMemory }!model.Config {
var zon_diag: std.zon.parse.Diagnostics = .{};
return std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) {
error.OutOfMemory => error.OutOfMemory,
error.ParseZon => {
try reportParseFailure(diags, &zon_diag);
return error.ParseZon;
},
};
}
/// Read and parse the managed file, everything a caller needs before
/// `validate.validate`. Validation is deliberately left to the caller: `check`
/// runs it beside its certificate and upstream probes, `run` runs it alone, and
/// both call the same validator on the same `Config`, which is what makes the
/// two agree.
///
/// `arena` owns both the source text and the returned configuration.
pub fn load(
io: std.Io,
arena: Allocator,
dir: std.Io.Dir,
path: []const u8,
diags: *validate.Diagnostics,
) Error!model.Config {
const source = try readManaged(io, arena, dir, path, diags);
return parse(arena, source, diags);
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
test "every path-class open failure is a managed-config fault" {
inline for (@typeInfo(PathFault).error_set.?) |member| {
const e = @field(ReadError, member.name);
try testing.expectEqual(error.ManagedConfigUnreadable, mapReadError(e));
}
}
test "a box fault outside the path class propagates unmapped" {
// Each of these exits 1: a retry can clear them, and the shipped unit's
// `RestartPreventExitStatus=2 64` would make exit 2 permanent.
try testing.expectEqual(error.SystemResources, mapReadError(error.SystemResources));
try testing.expectEqual(error.ProcessFdQuotaExceeded, mapReadError(error.ProcessFdQuotaExceeded));
try testing.expectEqual(error.SystemFdQuotaExceeded, mapReadError(error.SystemFdQuotaExceeded));
try testing.expectEqual(error.OutOfMemory, mapReadError(error.OutOfMemory));
try testing.expectEqual(error.InputOutput, mapReadError(error.InputOutput));
}
test "the size limit is its own fault, not an unreadable path" {
try testing.expectEqual(error.ConfigTooLarge, mapReadError(error.StreamTooLong));
}
test "the path class is exactly the eight members the ruling names" {
// A member added to `PathFault` without a decision recorded in the spec
// fails here rather than quietly moving an exit code from 1 to 2.
const expected = [_][]const u8{
"FileNotFound", "AccessDenied", "PermissionDenied", "NotDir",
"IsDir", "SymLinkLoop", "NameTooLong", "BadPathName",
};
const members = @typeInfo(PathFault).error_set.?;
try testing.expectEqual(expected.len, members.len);
inline for (members) |member| {
var found = false;
for (expected) |name| {
if (std.mem.eql(u8, name, member.name)) found = true;
}
try testing.expect(found);
}
}
test "a missing managed file records the path and the reason" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
try testing.expectError(
error.ManagedConfigUnreadable,
load(testing.io, arena_state.allocator(), tmp.dir, "nope.zon", &diags),
);
try testing.expectEqual(@as(usize, 1), diags.failureCount());
try testing.expectEqualStrings("nope.zon", diags.problems.items[0].path);
try testing.expectEqualStrings("no such file", diags.problems.items[0].message);
}
test "a directory named as the managed file is a configuration fault, not a crash" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDirPath(testing.io, "sub");
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
try testing.expectError(
error.ManagedConfigUnreadable,
load(testing.io, arena_state.allocator(), tmp.dir, "sub", &diags),
);
try testing.expectEqual(@as(usize, 1), diags.failureCount());
}
test "load parses a valid file and renders a syntax error line by line" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(testing.io, .{ .sub_path = "good.zon", .data =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\}
});
try tmp.dir.writeFile(testing.io, .{ .sub_path = "bad.zon", .data = ".{ .groups = " });
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var good_diags: validate.Diagnostics = .init(testing.allocator);
defer good_diags.deinit();
const cfg = try load(testing.io, arena, tmp.dir, "good.zon", &good_diags);
try testing.expectEqual(@as(usize, 0), good_diags.problems.items.len);
try testing.expectEqual(@as(usize, 1), cfg.upstreams.len);
var bad_diags: validate.Diagnostics = .init(testing.allocator);
defer bad_diags.deinit();
try testing.expectError(
error.ParseZon,
load(testing.io, arena, tmp.dir, "bad.zon", &bad_diags),
);
try testing.expect(bad_diags.failureCount() >= 1);
try testing.expectEqualStrings("config", bad_diags.problems.items[0].path);
}
+76 -11
View File
@@ -87,10 +87,16 @@ pub const Web = struct {
enabled: bool = true,
bind: []const u8 = "0.0.0.0",
port: u16 = 8080,
/// Operator input only. Never a settings row, always exported as "".
password: []const u8 = "",
/// argon2id PHC string; "" disables authentication.
password_hash: []const u8 = "",
/// Operator input only. Never a settings row, never exported.
///
/// Optional because absence and emptiness are different declarations: null
/// means "the file says nothing about the password, keep the stored hash",
/// while a present value is an instruction to set one.
password: ?[]const u8 = null,
/// argon2id PHC string. Null means "the file says nothing, keep what is
/// stored"; an explicit `""` is the documented way to disable
/// authentication.
password_hash: ?[]const u8 = null,
session_ttl_hours: u16 = 24,
api_rate_limit_per_min: u32 = 300,
/// Requests from the box itself skip the API rate limit. On by default: a
@@ -387,9 +393,23 @@ fn isScalarSection(comptime T: type) bool {
return @typeInfo(T) == .@"struct";
}
/// `web.password` is operator input, never a settings row: it is hashed into
/// `web.password_hash` at import time and discarded (S2.5).
fn isSkipped(comptime section: []const u8, comptime field: []const u8) bool {
/// The skip policy splits by direction, because encode and decode need
/// different sets.
///
/// `web.password` is operator input and is skipped both ways: it is hashed into
/// `web.password_hash` and discarded (S2.5).
///
/// `web.password_hash` is skipped on **encode only**. The reconcile engine owns
/// that settings row directly — ruling 4 of milestone 20 makes absence mean
/// "keep the stored hash", which a general encode pass cannot express. Skipping
/// it on decode as well would leave `cfg.web.password_hash` null on every read
/// path, turn `auth.authEnabled` false, and silently open the admin UI.
fn isEncodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
if (!std.mem.eql(u8, section, "web")) return false;
return std.mem.eql(u8, field, "password") or std.mem.eql(u8, field, "password_hash");
}
fn isDecodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
}
@@ -416,6 +436,10 @@ fn decodeValue(comptime T: type, text: []const u8) error{BadSettingValue}!T {
.int => std.fmt.parseInt(T, text, 10) catch error.BadSettingValue,
.@"enum" => T.fromDb(text) orelse error.BadSettingValue,
.pointer => text,
// A stored key is a present value, so an optional field decodes to a
// non-null one; the null stays reserved for the absent key, which never
// reaches this function at all.
.optional => |info| try decodeValue(info.child, text),
else => @compileError("unsupported setting field type " ++ @typeName(T)),
};
}
@@ -441,7 +465,7 @@ pub fn toSettings(cfg: Config, gpa: Allocator, out: *std.ArrayList(SettingPair))
if (comptime isScalarSection(section_field.type)) {
const section = @field(cfg, section_field.name);
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
if (comptime !isEncodeSkipped(section_field.name, field.name)) {
const value = try encodeValue(field.type, @field(section, field.name), gpa);
errdefer gpa.free(value);
try out.append(gpa, .{ .key = section_field.name ++ "." ++ field.name, .value = value });
@@ -465,7 +489,7 @@ pub fn fromSettings(pairs: []const SettingPair, cfg: *Config, unknown_keys: *usi
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) {
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
if (comptime !isDecodeSkipped(section_field.name, field.name)) {
if (std.mem.eql(u8, pair.key, section_field.name ++ "." ++ field.name)) {
@field(@field(cfg, section_field.name), field.name) =
try decodeValue(field.type, pair.value);
@@ -531,7 +555,6 @@ const expected_keys = [_][]const u8{
"web.api_rate_limit_per_min",
"web.bind",
"web.enabled",
"web.password_hash",
"web.port",
"web.session_ttl_hours",
"web.sse_max_connections_per_ip",
@@ -642,7 +665,7 @@ test "toSettings and fromSettings round-trip a non-default config" {
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) {
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
if (comptime !isEncodeSkipped(section_field.name, field.name)) {
const a = @field(@field(original, section_field.name), field.name);
const b = @field(@field(restored, section_field.name), field.name);
if (comptime @typeInfo(field.type) == .pointer) {
@@ -671,6 +694,48 @@ test "an unknown settings key is counted and not an error" {
try testing.expectEqual(@as(usize, 2), unknown);
}
test "web.password_hash decodes from the settings table but is never encoded" {
const gpa = testing.allocator;
var pairs: std.ArrayList(SettingPair) = .empty;
defer {
freeSettings(gpa, pairs.items);
pairs.deinit(gpa);
}
// Encode: the reconciler owns that row, so no pass over the model emits it.
const hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def";
try toSettings(.{ .web = .{ .password_hash = hash } }, gpa, &pairs);
for (pairs.items) |pair| {
try testing.expect(!std.mem.eql(u8, pair.key, "web.password_hash"));
try testing.expect(!std.mem.eql(u8, pair.key, "web.password"));
}
// Decode: every read path still sees the stored hash, or `authEnabled`
// would read false on a box that has a password set.
var cfg: Config = .{};
var unknown: usize = 0;
const stored = [_]SettingPair{.{ .key = "web.password_hash", .value = hash }};
try fromSettings(&stored, &cfg, &unknown);
try testing.expectEqual(@as(usize, 0), unknown);
try testing.expectEqualStrings(hash, cfg.web.password_hash.?);
}
test "an optional settings field is null when absent and non-null when present" {
var absent: Config = .{};
var unknown: usize = 0;
const other = [_]SettingPair{.{ .key = "dns.port", .value = "5300" }};
try fromSettings(&other, &absent, &unknown);
try testing.expectEqual(@as(?[]const u8, null), absent.web.password_hash);
// An explicit empty string is a present value, not an absent key: it is how
// a config file disables authentication.
var empty: Config = .{};
const disabled = [_]SettingPair{.{ .key = "web.password_hash", .value = "" }};
try fromSettings(&disabled, &empty, &unknown);
try testing.expect(empty.web.password_hash != null);
try testing.expectEqualStrings("", empty.web.password_hash.?);
}
test "a malformed settings value is BadSettingValue" {
var cfg: Config = .{};
var unknown: usize = 0;
File diff suppressed because it is too large Load Diff
+56 -2
View File
@@ -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
+113
View File
@@ -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);
}
// ---------------------------------------------------------------------------
// 1112: local records, from the database to the wire
// ---------------------------------------------------------------------------
+42
View File
@@ -1199,6 +1199,14 @@ pub const Manager = struct {
if (state != .ok) return true;
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();
+5 -3
View File
@@ -965,10 +965,12 @@ test "S7 case 11: the app boots, serves a query and exits zero on shutdown" {
shutdown.reset();
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 });
+24 -21
View File
@@ -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;
+10
View File
@@ -57,6 +57,7 @@ pub const c = struct {
pub extern fn sqlite3_column_bytes(stmt: *c.Stmt, col: c_int) c_int;
pub extern fn sqlite3_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 {
+2 -2
View File
@@ -353,7 +353,7 @@ test "readVersion reads a file database through an immutable open, writing nothi
try testing.expectEqual(@as(u32, 1), try readVersion(&database));
}
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`.
+69 -7
View File
@@ -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
// ---------------------------------------------------------------------------
+35
View File
@@ -254,6 +254,41 @@ pub fn setGroupSources(database: *db.Db, group_id: i64, source_ids: []const i64)
try tx.commit();
}
// ---------------------------------------------------------------------------
// reconcile surface (milestone 20)
// ---------------------------------------------------------------------------
//
// `group_sources` has no row id — the pair *is* the identity — so the reconcile
// engine matches on the pair and needs the ids the name-keyed list above
// resolves away.
pub const GroupSourcePair = struct { group_id: i64, source_id: i64 };
/// Every assignment as the pair of ids it is. Nothing to free.
pub fn listGroupSourcePairs(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(GroupSourcePair) {
return crud.listRows(
GroupSourcePair,
database,
gpa,
"SELECT group_id, source_id FROM group_sources ORDER BY group_id, source_id",
readGroupSourcePair,
);
}
fn readGroupSourcePair(stmt: *db.Stmt, gpa: Allocator) db.Error!GroupSourcePair {
_ = gpa;
return .{ .group_id = stmt.columnInt(0), .source_id = stmt.columnInt(1) };
}
/// Removes one assignment. `error.NotFound`: no row holds the pair.
pub fn deleteGroupSourcePair(database: *db.Db, pair: GroupSourcePair) db.Error!void {
var stmt = try database.prepare("DELETE FROM group_sources WHERE group_id = ?1 AND source_id = ?2");
defer stmt.deinit();
try stmt.bindInt(1, pair.group_id);
try stmt.bindInt(2, pair.source_id);
return crud.execStrict(database, &stmt);
}
fn groupExists(database: *db.Db, id: i64) db.Error!bool {
var stmt = try database.prepare("SELECT 1 FROM groups WHERE id = ?1");
defer stmt.deinit();
@@ -88,6 +88,18 @@ pub fn putSetting(database: *db.Db, key: []const u8, value: []const u8) db.Error
try stmt.exec();
}
/// Removes one key. Silent about a key that is not stored: the reconcile
/// engine's sweep computes the set of keys to drop from a list it has already
/// read, so "no such row" is not a caller error the way it is for a by-id
/// mutation, and `execStrict` would turn a harmless race into a failed
/// transaction.
pub fn deleteSetting(database: *db.Db, key: []const u8) db.Error!void {
var stmt = try database.prepare("DELETE FROM settings WHERE key = ?1");
defer stmt.deinit();
try stmt.bindText(1, key);
return stmt.exec();
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
+20 -135
View File
@@ -22,7 +22,6 @@ const build_options = @import("build_options");
const Writer = std.Io.Writer;
const cli = @import("../cli.zig");
const bootstrap = @import("../config/bootstrap.zig");
const config_export = @import("../config/export.zig");
const import = @import("../config/import.zig");
const model = @import("../config/model.zig");
@@ -127,7 +126,7 @@ fn openMigrated(f: *const Fixture, name: []const u8) !Data {
return .{ .dir = dir, .database = database };
}
fn importInto(f: *const Fixture, data: *Data, file: []const u8, force: bool) !void {
fn importInto(f: *const Fixture, data: *Data, file: []const u8, allow_delete: bool) !void {
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
return import.importFile(
@@ -136,7 +135,7 @@ fn importInto(f: *const Fixture, data: *Data, file: []const u8, force: bool) !vo
&data.database,
f.tmp.dir,
file,
.{ .force = force },
.{ .allow_delete = allow_delete },
&diags,
);
}
@@ -655,7 +654,7 @@ test "S7 case 10: a config.db stamped one version ahead is refused and left alon
}
// ---------------------------------------------------------------------------
// case 11-19: export, import and bootstrap on real files
// case 11-19: export and import on real files
// ---------------------------------------------------------------------------
test "S7 case 11: an exported file is mode 0600 and starts with the header comment" {
@@ -704,7 +703,7 @@ test "S7 case 12: export, import and export again are byte-identical files" {
try testing.expectEqualStrings(a, b);
}
test "S7 case 13: import refuses a configured database unless --force is given" {
test "S7 case 13: import refuses a diff that deletes rows unless --allow-delete is given" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
@@ -716,7 +715,10 @@ test "S7 case 13: import refuses a configured database unless --force is given"
defer data.deinit();
try importInto(&f, &data, "first.zon", false);
try testing.expectError(error.DatabaseNotEmpty, importInto(&f, &data, "second.zon", false));
// The two files name different upstream urls, and a url is the upstream's
// identity: applying the second deletes the first's row, which is what the
// gate exists to stop.
try testing.expectError(error.DestructiveImport, importInto(&f, &data, "second.zon", false));
try testing.expectEqual(
@as(i64, 1),
try data.database.queryInt(
@@ -753,14 +755,15 @@ test "S7 case 14: an invalid import reports every problem and writes nothing" {
&data.database,
f.tmp.dir,
"config.zon",
.{ .force = false },
.{ .allow_delete = false },
&diags,
)) |_| {
return error.TestUnexpectedResult;
} else |_| {}
try testing.expectEqual(@as(usize, 2), diags.problems.items.len);
try testing.expect(try import.isEmpty(&data.database));
try testing.expectEqual(@as(i64, 0), try data.database.queryInt("SELECT count(*) FROM upstreams"));
try testing.expectEqual(@as(i64, 0), try data.database.queryInt("SELECT count(*) FROM settings"));
// Nothing beyond the database and its sidecars was created.
var dir = try f.tmp.dir.openDir(io, "data", .{ .iterate = true });
@@ -771,129 +774,6 @@ test "S7 case 14: an invalid import reports every problem and writes nothing" {
}
}
test "S7 case 15: bootstrap with no configuration file leaves the database empty" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
var data = try openMigrated(&f, "data");
defer data.deinit();
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
const outcome = try bootstrap.bootstrap(
io,
testing.allocator,
&data.database,
f.tmp.dir,
"config.zon",
&diags,
);
try testing.expectEqual(bootstrap.Outcome.no_config_file, outcome);
try testing.expect(try import.isEmpty(&data.database));
}
test "S7 case 16: bootstrap seeds an empty database from the configuration file" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try f.write("config.zon", rich_config);
var data = try openMigrated(&f, "data");
defer data.deinit();
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
const outcome = try bootstrap.bootstrap(
io,
testing.allocator,
&data.database,
f.tmp.dir,
"config.zon",
&diags,
);
try testing.expectEqual(bootstrap.Outcome.seeded, outcome);
try testing.expectEqual(@as(i64, 2), try data.database.queryInt("SELECT count(*) FROM groups"));
try testing.expectEqual(@as(i64, 2), try data.database.queryInt("SELECT count(*) FROM upstreams"));
try testing.expectEqual(
@as(i64, 1),
try data.database.queryInt("SELECT count(*) FROM clients WHERE ip = 'fd00::1'"),
);
try testing.expectEqual(
@as(i64, 5353),
try data.database.queryInt("SELECT CAST(value AS INTEGER) FROM settings WHERE key = 'dns.port'"),
);
}
test "S7 case 17: bootstrap on a configured database never reads the file" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try f.write("seed.zon", minimal_config);
var data = try openMigrated(&f, "data");
defer data.deinit();
try importInto(&f, &data, "seed.zon", false);
// Unparseable on purpose: the call can only succeed if the file is never
// opened.
try f.write("config.zon", broken_zon);
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
const outcome = try bootstrap.bootstrap(
io,
testing.allocator,
&data.database,
f.tmp.dir,
"config.zon",
&diags,
);
try testing.expectEqual(bootstrap.Outcome.db_already_configured, outcome);
try testing.expectEqual(@as(usize, 0), diags.problems.items.len);
try testing.expectEqual(
@as(i64, 1),
try data.database.queryInt(
"SELECT count(*) FROM upstreams WHERE url = 'https://dns.example/dns-query'",
),
);
}
test "S7 case 18: bootstrap with an invalid configuration file fails and writes nothing" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try f.write("config.zon", two_problem_config);
var data = try openMigrated(&f, "data");
defer data.deinit();
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
if (bootstrap.bootstrap(
io,
testing.allocator,
&data.database,
f.tmp.dir,
"config.zon",
&diags,
)) |outcome| {
std.debug.print("bootstrap unexpectedly returned .{s}\n", .{@tagName(outcome)});
return error.TestUnexpectedResult;
} else |_| {}
try testing.expectEqual(@as(usize, 2), diags.problems.items.len);
try testing.expect(try import.isEmpty(&data.database));
}
test "S7 case 19: writeToFile replaces an existing file and restores mode 0600" {
if (!build_options.integration) return error.SkipZigTest;
@@ -988,11 +868,13 @@ test "S7 case 21: runCheck passes a seeded database and reports two stored probl
try importInto(&f, &data, "config.zon", false);
}
{
// `applyToDb` rather than an import: the validator would refuse this
// `apply` rather than an import: the validator would refuse this
// configuration, and the case needs the problems to reach the database.
var data = try openMigrated(&f, "bad");
defer data.deinit();
try import.applyToDb(io, testing.allocator, &data.database, two_problem_model, 42, .{});
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
try import.apply(io, testing.allocator, &data.database, two_problem_model, 42, .{}, &diags);
}
{
@@ -1045,13 +927,16 @@ test "S7 case 22: runCheck probes a real upstream and prints an OK line" {
const code = cli.runCheck(
captured.runner(),
.{ .paths = .{ .config = config_path }, .config_explicit = true },
.{ .config = config_path },
true,
);
try testing.expectEqual(cli.exit_ok, code);
// Milestone 13 changed the probe line to the redacted `OK upstreams[i]`
// form; this expectation went stale unnoticed because nothing ran -Dlive
// between then and milestone 20.
try testing.expect(std.mem.count(
u8,
captured.out.written(),
"OK https://cloudflare-dns.com/dns-query\n",
"OK upstreams[0] https://cloudflare-dns.com\n",
) == 1);
}
+2 -1
View File
@@ -49,7 +49,8 @@ comptime {
_ = @import("storage/repositories/settings_repo.zig");
_ = @import("config/export.zig");
_ = @import("config/import.zig");
_ = @import("config/bootstrap.zig");
_ = @import("config/loader.zig");
_ = @import("config/reconcile.zig");
_ = @import("cli.zig");
_ = @import("storage/storage_integration_test.zig");
_ = @import("filter/parsers.zig");
+6 -2
View File
@@ -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" }));
}
+110 -1
View File
@@ -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);
+65 -14
View File
@@ -125,9 +125,15 @@ fn Partial(comptime Section: type, comptime section_name: []const u8) type {
/// Enums arrive as the words the database stores, so they are parsed from text
/// rather than by tag name (`logging.level` is `error`, whose tag cannot be).
///
/// An optional model field collapses to its child, because `Partial` wraps
/// every field in one optional of its own and that optional already carries the
/// only meaning a PUT has for absence — "leave it". A double optional would be
/// two ways to say the same thing, and `std.json` cannot parse the outer one.
fn FieldType(comptime T: type) type {
return switch (@typeInfo(T)) {
.@"enum" => []const u8,
.optional => |info| FieldType(info.child),
else => T,
};
}
@@ -321,16 +327,17 @@ pub fn applyPut(
// The password never becomes a row: the hash made above is what the merged
// configuration — and therefore the settings table — carries.
const previous_hash = cfg.web.password_hash;
const previous_hash = cfg.web.password_hash orelse "";
if (password != null) cfg.web.password_hash = new_hash;
cfg.web.password = "";
cfg.web.password = null;
if (try problem(arena, cfg)) |text| return .{ .fail = .{ .invalid = text } };
// The gpa copy the live holder will own, made before the write so a
// committed transaction can never be followed by a failed revocation.
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, cfg.web.password_hash);
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, cfg.web.password_hash) else null;
const merged_hash = cfg.web.password_hash orelse "";
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, merged_hash);
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, merged_hash) else null;
writeSettings(arena, database, cfg) catch |err| {
if (replacement) |hash| state.gpa.free(hash);
@@ -365,6 +372,12 @@ fn writeSettings(arena: Allocator, database: *db.Db, cfg: model.Config) db.Error
for (pairs.items) |pair| {
try settings_repo.putSetting(database, pair.key, pair.value);
}
// `toSettings` stops at `web.password_hash` (ruling 4 of milestone 20: the
// reconcile engine owns that row, because only it can tell "the file said
// nothing" from "the file said empty"). A PUT has no such ambiguity — the
// merged configuration is the whole truth — so this handler writes the row
// itself rather than losing the password change.
try settings_repo.putSetting(database, "web.password_hash", cfg.web.password_hash orelse "");
try tx.commit();
}
@@ -455,6 +468,36 @@ pub const hash_stall_control = if (builtin.is_test) struct {
// routes
// ---------------------------------------------------------------------------
/// Which source governs this process's configuration, and when it last read
/// it (milestone-20 ruling 7). This is how the UI learns that configuration is
/// read-only — declaratively, rather than by probing a route for a 403.
///
/// It rides `GET /api/settings` because that route needs a session: the
/// managed path is a filesystem path and must never reach the open
/// `/api/version` or `/api/health`.
///
/// `reconciled_at` means exactly "this process loaded the file at T". A file
/// whose mtime is newer has not been loaded by the running process. It cannot
/// answer "is the file what the server uses" — a stepped clock or a preserved
/// mtime defeats the comparison in either direction, and the database can move
/// under `nxdns import` without either timestamp moving.
const AuthorityView = struct {
mode: []const u8,
path: ?[]const u8,
reconciled_at: ?i64,
};
fn authorityView(state: *const server.WebState) AuthorityView {
return switch (state.authority) {
.database => .{ .mode = "database", .path = null, .reconciled_at = state.reconciled_at },
.managed_file => |path| .{
.mode = "managed_file",
.path = path,
.reconciled_at = state.reconciled_at,
},
};
}
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const database = mutations.requireConfigDb(state) catch
return mutations.respondFailure(request, mutations.no_config_db, "reading the settings");
@@ -470,7 +513,7 @@ pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
const cfg = loaded catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading the settings");
return respondSettings(request, .ok, cfg);
return respondSettings(request, state, .ok, cfg);
}
pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
@@ -479,14 +522,20 @@ pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
return switch (try applyPut(state, io, request.arena, parsed.value)) {
.fail => |failure| mutations.respondFailure(request, failure, "writing the settings"),
.config => |cfg| respondSettings(request, .ok, cfg),
.config => |cfg| respondSettings(request, state, .ok, cfg),
};
}
fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config) HandlerError!void {
fn respondSettings(
request: *Request,
state: *const server.WebState,
status: std.http.Status,
cfg: model.Config,
) HandlerError!void {
return http_util.respondJson(request, status, .{
.settings = view(cfg),
.restart_required = restart_required_keys,
.authority = authorityView(state),
}, &.{});
}
@@ -498,8 +547,10 @@ const testing = std.testing;
const auth_handlers = @import("auth.zig");
test "the restart-required table lists every settings key and no secret" {
// `model.toSettings` is the other half of the same fact: the keys the
// database stores, minus the hash the API never serializes.
// `model.toSettings` is the other half of the same fact. The two lists are
// now equal rather than off by one: `toSettings` stopped emitting
// `web.password_hash` (milestone 20 ruling 4) and this table never listed
// it, so both exclude the hash and the plaintext.
var pairs: std.ArrayList(model.SettingPair) = .empty;
defer {
model.freeSettings(testing.allocator, pairs.items);
@@ -507,7 +558,7 @@ test "the restart-required table lists every settings key and no secret" {
}
try model.toSettings(.{}, testing.allocator, &pairs);
try testing.expectEqual(pairs.items.len - 1, restart_required_keys.len);
try testing.expectEqual(pairs.items.len, restart_required_keys.len);
for (restart_required_keys) |key| {
try testing.expect(!std.mem.eql(u8, key, "web.password_hash"));
try testing.expect(!std.mem.eql(u8, key, "web.password"));
@@ -641,7 +692,7 @@ test "a new password is stored as a hash and ends every session" {
patch.web = .{ .password = "correct horse battery staple" };
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash, "$argon2id$"));
try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash.?, "$argon2id$"));
try testing.expect(!sessions.validateAt(bench.io(), &cookie, 1_001));
// The plain password is nowhere in the table, and the hash is.
@@ -650,10 +701,10 @@ test "a new password is stored as a hash and ends every session" {
try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.password'"),
);
const stored = try mutations.loadConfig(bench.arena(), &bench.database);
try testing.expect(std.mem.startsWith(u8, stored.web.password_hash, "$argon2id$"));
try testing.expect(std.mem.startsWith(u8, stored.web.password_hash.?, "$argon2id$"));
try testing.expectEqual(
auth.Outcome.ok,
try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash, "correct horse battery staple"),
try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash.?, "correct horse battery staple"),
);
}
@@ -713,7 +764,7 @@ test "an empty password is not a password change" {
patch.web = .{ .password = "" };
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
try testing.expectEqualStrings("", outcome.config.web.password_hash);
try testing.expectEqualStrings("", outcome.config.web.password_hash orelse "");
}
test "reading the settings with no database is unavailable" {
+25 -10
View File
@@ -307,23 +307,38 @@ pub fn parseBody(comptime T: type, request: *Request) (BodyError || error{BadJso
/// Ruling 8's envelope. `message` is operator-facing text, never a raw internal
/// error string for a 500 (PLAN §19: details go to the log, not the wire).
///
/// Built on the request arena, like `respondJson` below. It used to build into
/// a 512-byte stack buffer and fall back to `text/plain` when the message
/// overflowed it, which made the documented JSON envelope a function of message
/// length — a long managed-file path (milestone-20 ruling 7) was enough to
/// demote it. The envelope is `application/json` at every length now.
pub fn respondError(
request: *Request,
status: http.Status,
message: []const u8,
) HandlerError!void {
var buf: [512]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var stringify: std.json.Stringify = .{ .writer = &writer };
stringify.beginObject() catch return respondPlain(request, status, message);
stringify.objectField("error") catch return respondPlain(request, status, message);
stringify.write(message) catch return respondPlain(request, status, message);
stringify.endObject() catch return respondPlain(request, status, message);
return respondBytes(request, status, writer.buffered(), content_type_json, &.{});
var allocating: std.Io.Writer.Allocating = .init(request.arena);
defer allocating.deinit();
var stringify: std.json.Stringify = .{ .writer = &allocating.writer };
stringify.beginObject() catch return error.OutOfMemory;
stringify.objectField("error") catch return error.OutOfMemory;
stringify.write(message) catch return error.OutOfMemory;
stringify.endObject() catch return error.OutOfMemory;
return respondBytes(request, status, allocating.written(), content_type_json, &.{});
}
fn respondPlain(request: *Request, status: http.Status, message: []const u8) HandlerError!void {
return respondBytes(request, status, message, content_type_text, &.{});
/// Milestone-20 ruling 7's rejection: a configuration write under file
/// authority. One function, because the router rejects most of them and the
/// clients handler rejects the one that needs a row read — two wordings would
/// be two contracts.
pub fn respondManagedByFile(request: *Request, path: []const u8) HandlerError!void {
const message = try std.fmt.allocPrint(
request.arena,
"configuration is managed by {s}; edit the file and restart",
.{path},
);
return respondError(request, .forbidden, message);
}
/// Serialises `value` and responds. The document is built in the request arena
+99 -1
View File
@@ -29,6 +29,15 @@ info:
- Mutations to groups, blocklists, rules, local records, forward zones,
clients and client prefixes take effect live. Upstreams and
`/api/settings` are restart-required.
- nxdns runs under one of two configuration authorities. Started with
`--config=<file>`, that file is the sole declarative source, and every
operation that writes configuration answers 403 with the same error
envelope, naming the file. Operations that change runtime state —
`/api/pause`, `POST /api/blocklists/update`, `/api/certs/reload`, the
login and the logout — stay live, as does `DELETE /api/clients/{id}`
for a client the file does not declare. `GET /api/settings` reports
the live authority, so a client reads the mode rather than
discovering it from a rejection.
servers:
- url: /
@@ -391,6 +400,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -444,6 +455,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -466,6 +479,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -519,6 +534,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -575,6 +592,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -655,6 +674,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -674,6 +695,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -728,6 +751,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -780,6 +805,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -799,6 +826,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -853,6 +882,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -905,6 +936,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -924,6 +957,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -978,6 +1013,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -1030,6 +1067,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1049,6 +1088,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1133,6 +1174,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1222,6 +1265,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -1277,6 +1322,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -1330,6 +1377,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1350,6 +1399,8 @@ paths:
description: Deleted; takes effect on restart.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1454,6 +1505,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"413":
$ref: "#/components/responses/BodyTooLarge"
"429":
@@ -1526,6 +1579,16 @@ components:
application/json:
schema:
$ref: "#/components/schemas/Error"
ManagedByFile:
description: |
nxdns is running under file authority and this operation writes
configuration. The message names the file. Authentication is checked
first, so an unauthenticated request to a protected route still
answers 401 rather than disclosing that the route exists.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
NotFound:
description: No row has this id.
content:
@@ -2193,7 +2256,7 @@ components:
SettingsEnvelope:
type: object
required: [settings, restart_required]
required: [settings, restart_required, authority]
properties:
settings:
$ref: "#/components/schemas/Settings"
@@ -2203,6 +2266,41 @@ components:
description: |
Every `section.field` key that needs a restart to take effect —
currently all of them.
authority:
$ref: "#/components/schemas/Authority"
Authority:
type: object
description: |
Which source governs this process's configuration. This is how a
client learns that configuration is read-only; it never has to probe
a write route for a 403. The block rides this authenticated endpoint
because `path` is a filesystem path, and never appears on the open
`/api/version` or `/api/health`.
required: [mode, path, reconciled_at]
properties:
mode:
type: string
enum: [database, managed_file]
description: |
`database` when nxdns runs without `--config`; `managed_file`
when it runs with it, in which case every configuration write
answers 403.
path:
type: string
nullable: true
description: The managed file, or null in `database` mode.
reconciled_at:
type: integer
nullable: true
description: |
When this process loaded the managed file, in epoch seconds, and
null in `database` mode. It means exactly that: a file whose
mtime is newer has not been loaded by the running process. It
cannot answer whether the file matches what the server serves —
a stepped clock or a preserved mtime defeats the comparison
either way, and `nxdns import` can move the database without
moving either timestamp.
SettingsPatch:
type: object
+78
View File
@@ -48,6 +48,84 @@ test "every served route appears textually in the document" {
}
}
// Drift guard for milestone-20 ruling 7: a route classified `config_write` can
// answer 403 under file authority, so its operation must say so — and a route
// that cannot must not claim it. Textual, like the coverage test above: the
// document has no parser here, and the two facts it compares are one line each.
test "every config write documents the file-authority 403, and nothing else does" {
for (router.routes) |route| {
const operation = try operationBlock(route.pattern, route.method);
const documented = std.mem.containsAtLeast(u8, operation, 1, "\n \"403\":\n");
if (documented != (route.policy == .config_write)) {
std.debug.print(
"{t} {s} is {t} but {s} a 403\n",
.{ route.method, route.pattern, route.policy, if (documented) "documents" else "does not document" },
);
return error.TestUnexpectedResult;
}
}
}
/// The body of one operation: everything under `pattern`'s `method` key.
///
/// The path block is bounded *before* the method is looked for. Searching the
/// rest of the document instead would let a later path's `delete:` answer for a
/// path that has none, and the guard above would pass on an operation nobody
/// documented.
fn operationBlock(pattern: []const u8, method: std.http.Method) ![]const u8 {
var key_buf: [128]u8 = undefined;
const path_key = try std.fmt.bufPrint(&key_buf, "\n {s}:\n", .{pattern});
const path_at = std.mem.indexOf(u8, yaml, path_key) orelse return error.PathNotDocumented;
const path_body = blockUnder(yaml[path_at + path_key.len ..], 2);
var method_buf: [16]u8 = undefined;
const method_key = try std.fmt.bufPrint(&method_buf, " {s}:\n", .{@tagName(method)});
_ = std.ascii.lowerString(&method_buf, method_key);
const key = method_buf[0..method_key.len];
// Anchored at a line start: a `get:` nested deeper inside a description
// contains the four-space key as a substring.
var offset: usize = 0;
while (offset < path_body.len) {
if (std.mem.startsWith(u8, path_body[offset..], key)) {
return blockUnder(path_body[offset + key.len ..], 4);
}
offset = (std.mem.indexOfScalarPos(u8, path_body, offset, '\n') orelse path_body.len) + 1;
}
return error.MethodNotDocumented;
}
/// The run of lines at the start of `body` indented deeper than `indent` — what
/// belongs to the key that just ended. `body` starts at a line boundary. Blank
/// lines belong to whatever surrounds them and never close a block.
fn blockUnder(body: []const u8, indent: usize) []const u8 {
var offset: usize = 0;
while (offset < body.len) {
const line_end = std.mem.indexOfScalarPos(u8, body, offset, '\n') orelse body.len;
if (line_end != offset) {
const depth = for (body[offset..line_end], 0..) |c, i| {
if (c != ' ') break i;
} else line_end - offset;
if (depth <= indent) return body[0..offset];
}
offset = line_end + 1;
}
return body;
}
test "an operation block stops at its own path and its own method" {
// `/api/groups` has no DELETE. An unbounded search answers with the one
// under `/api/groups/{id}`, and the 403 guard then grades the wrong
// operation — silently passing for a route nobody documented.
try testing.expectError(error.MethodNotDocumented, operationBlock("/api/groups", .DELETE));
// A block it does have never reaches into its neighbour under the same
// path either.
const list_groups = try operationBlock("/api/groups", .GET);
try testing.expect(std.mem.containsAtLeast(u8, list_groups, 1, "List groups"));
try testing.expect(!std.mem.containsAtLeast(u8, list_groups, 1, "Create a group"));
}
test "the document names the contract's fixed points" {
for ([_][]const u8{
"openapi: 3.0.3",
+55 -7
View File
@@ -37,12 +37,20 @@ pub const Auth = enum { open, session };
/// monitoring endpoints so a Prometheus scrape can never be throttled.
pub const RateLimit = enum { counted, exempt };
/// What a route does to the configuration, and therefore whether file
/// authority may allow it (milestone-20 ruling 7). `config_write` changes the
/// declarative state the managed file owns; `runtime_action` changes runtime
/// state the file never declares; `read` changes nothing.
pub const Policy = enum { read, config_write, runtime_action };
pub const RouteInfo = struct {
method: http.Method,
/// Segments separated by `/`, with at most one `{id}` capture, which must
/// be a positive integer row id.
pattern: []const u8,
auth: Auth,
/// No default: a new route states its class or does not compile.
policy: Policy,
handler: HandlerFn,
rate_limit: RateLimit = .counted,
};
@@ -158,6 +166,17 @@ pub fn dispatch(
return http_util.respondError(request, .unauthorized, "authentication required");
}
// Milestone-20 ruling 7, and it runs *after* the auth check on purpose:
// rejecting before authenticating would tell an anonymous caller which
// routes exist. An unauthenticated request to a protected route answers
// 401 in both authority modes.
if (found.route.policy == .config_write) {
switch (state.authority) {
.database => {},
.managed_file => |path| return http_util.respondManagedByFile(request, path),
}
}
return found.route.handler(state, io, request);
}
@@ -196,13 +215,14 @@ fn noopHandler(
}
const test_table = [_]RouteInfo{
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = noopHandler, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = noopHandler },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = noopHandler },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = noopHandler },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = noopHandler, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = noopHandler },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .handler = noopHandler },
};
fn matchPath(method: http.Method, path: []const u8) Match {
@@ -263,6 +283,34 @@ test "the allow header lists every method the path accepts" {
try testing.expectEqualStrings("GET, PUT, DELETE", formatAllow(&test_table, item.segments(), &buf));
}
test "matching carries the class the table declares, per route and not per prefix" {
const cases = [_]struct { method: http.Method, path: []const u8, policy: Policy }{
.{ .method = .GET, .path = "/api/groups", .policy = .read },
.{ .method = .GET, .path = "/api/groups/7", .policy = .read },
.{ .method = .POST, .path = "/api/groups", .policy = .config_write },
.{ .method = .PUT, .path = "/api/groups/7", .policy = .config_write },
.{ .method = .DELETE, .path = "/api/groups/7", .policy = .config_write },
.{ .method = .PUT, .path = "/api/groups/7/sources", .policy = .config_write },
// Same prefix, different class: the column is per route.
.{ .method = .POST, .path = "/api/pause", .policy = .runtime_action },
};
for (cases) |case| {
try testing.expectEqual(case.policy, matchPath(case.method, case.path).found.route.policy);
}
}
test "the shipped route table classifies /api/blocklists by route, not by prefix" {
var refresh: ?Policy = null;
var create: ?Policy = null;
for (routes) |route| {
if (route.method != .POST) continue;
if (std.mem.eql(u8, route.pattern, "/api/blocklists/update")) refresh = route.policy;
if (std.mem.eql(u8, route.pattern, "/api/blocklists")) create = route.policy;
}
try testing.expectEqual(Policy.runtime_action, refresh.?);
try testing.expectEqual(Policy.config_write, create.?);
}
test "the shipped route table is the one the router matches against" {
try testing.expectEqual(routes_table.table.ptr, routes.ptr);
try testing.expectEqual(routes_table.table.len, routes.len);
+132 -56
View File
@@ -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}");
+21
View File
@@ -102,10 +102,31 @@ pub const ReloadFn = *const fn (state: *WebState, io: std.Io) anyerror!void;
/// false` means several of them are never opened at all (ruling 6). A handler
/// 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,
+5 -5
View File
@@ -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 {
+313 -56
View File
@@ -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)
// ---------------------------------------------------------------------------
+1
View File
@@ -1,3 +1,4 @@
dist/
dist-placeholder/
package-lock.json
dist-sourcemap/
@@ -10,7 +10,9 @@ test("swallowMutationError drops an ApiError and rethrows anything else", () =>
test("a rejected submit leaves the typed values in place; a resolved one clears them", async () => {
const rejecting = vi.fn(() => Promise.reject(new ApiError(400, "bad url")));
const { rerender } = render(<BlocklistForm busy={false} error={null} onSubmit={rejecting} onCancel={undefined} />);
const { rerender } = render(
<BlocklistForm busy={false} readOnly={false} error={null} onSubmit={rejecting} onCancel={undefined} />,
);
const url = screen.getByLabelText("URL") as HTMLInputElement;
const name = screen.getByLabelText("Name") as HTMLInputElement;
fireEvent.change(url, { target: { value: "https://example.com/list.txt" } });
@@ -22,7 +24,7 @@ test("a rejected submit leaves the typed values in place; a resolved one clears
expect(name.value).toBe("Example");
const resolving = vi.fn(() => Promise.resolve());
rerender(<BlocklistForm busy={false} error={null} onSubmit={resolving} onCancel={undefined} />);
rerender(<BlocklistForm busy={false} readOnly={false} error={null} onSubmit={resolving} onCancel={undefined} />);
fireEvent.click(screen.getByRole("button", { name: "Add source" }));
await waitFor(() => expect(url.value).toBe(""));
expect(name.value).toBe("");
+10 -2
View File
@@ -3,6 +3,7 @@ import { ApiError } from "@/lib/api";
import InlineError from "@/lib/InlineError";
import type { Blocklist, BlocklistInput } from "@/lib/types";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
import { READ_ONLY_HINT } from "@/features/settings/authority";
/**
* Drops the rejection the page already renders inline below the form. Anything
@@ -17,12 +18,14 @@ export function swallowMutationError(error: unknown): void {
interface BlocklistFormProps {
initial?: Blocklist;
busy: boolean;
/** File authority: the server answers 403, so the submit stays down. */
readOnly: boolean;
error: Error | null;
onSubmit: (input: BlocklistInput) => Promise<void>;
onCancel?: () => void;
}
export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel }: BlocklistFormProps) {
export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit, onCancel }: BlocklistFormProps) {
const [url, setUrl] = useState(initial?.url ?? "");
const [name, setName] = useState(initial?.name ?? "");
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
@@ -81,7 +84,12 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
Enabled
</label>
<div className="flex items-center gap-2">
<button type="submit" disabled={busy} className={primaryButtonClass}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
{initial === undefined ? "Add source" : "Save changes"}
</button>
{onCancel !== undefined && (
+12 -3
View File
@@ -22,6 +22,7 @@ import {
tdClass,
thClass,
} from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
export default function BlocklistsPage() {
const queryClient = useQueryClient();
@@ -36,6 +37,9 @@ export default function BlocklistsPage() {
const sources = useRefreshStatus();
const namesById = new Map(blocklists.map((b) => [b.id, b.name]));
// The refresh below re-fetches the sources the config already declares, so
// it stays live in file mode; every other control here writes config.
const readOnly = useReadOnlyConfig();
async function submitForm(input: BlocklistInput) {
if (editing === null) {
@@ -122,7 +126,8 @@ export default function BlocklistsPage() {
type="checkbox"
aria-label={`${b.name} enabled`}
checked={b.enabled}
disabled={toggle.isPending}
disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(b)}
className={focusRing}
/>
@@ -138,14 +143,17 @@ export default function BlocklistsPage() {
<button
type="button"
onClick={() => setEditing(b)}
className={linkButtonClass}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${linkButtonClass} disabled:opacity-50`}
>
Edit
</button>
<button
type="button"
onClick={() => deleteBlocklist(b)}
disabled={remove.isPending}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass}
>
Delete
@@ -164,6 +172,7 @@ export default function BlocklistsPage() {
key={editing?.id ?? "add"}
initial={editing ?? undefined}
busy={editing === null ? create.isPending : save.isPending}
readOnly={readOnly}
error={formError}
onSubmit={submitForm}
onCancel={editing === null ? undefined : () => setEditing(null)}
@@ -4,6 +4,7 @@ import { clientUpdateMutation } from "@/lib/queries";
import type { Client, Group } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import { buttonClass, primaryButtonClass, smallInputClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
client: Client;
@@ -18,6 +19,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
const mutation = useMutation(clientUpdateMutation(queryClient));
const [name, setName] = useState(client.name);
const [groupId, setGroupId] = useState(client.group_id);
const readOnly = useReadOnlyConfig();
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
@@ -67,7 +69,12 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
<button type="button" onClick={onClose} className={buttonClass}>
Cancel
</button>
<button type="submit" disabled={mutation.isPending} className={primaryButtonClass}>
<button
type="submit"
disabled={mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
Save
</button>
</div>
+19 -2
View File
@@ -7,9 +7,17 @@ import ClientEditDialog from "./ClientEditDialog";
import PrefixesEditor from "./PrefixesEditor";
import InlineError from "@/lib/InlineError";
import { smallButtonClass, tableWrapClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const cellClass = "px-3 py-2";
/**
* Deleting an observed row discards runtime state the file never declared, so
* it stays live under file authority; deleting a hand-edited row contradicts
* the file and is the one client DELETE the server answers 403 (ruling 7).
*/
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart.";
export default function ClientsPage() {
const { data: clients } = useSuspenseQuery(clientsQuery());
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
@@ -18,6 +26,7 @@ export default function ClientsPage() {
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
const [editing, setEditing] = useState<Client | null>(null);
const [confirmingId, setConfirmingId] = useState<number | null>(null);
const readOnly = useReadOnlyConfig();
return (
<section>
@@ -86,14 +95,22 @@ export default function ClientsPage() {
<button
type="button"
onClick={() => setEditing(client)}
className={smallButtonClass}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${smallButtonClass} disabled:opacity-50`}
>
Edit
</button>
<button
type="button"
onClick={() => setConfirmingId(client.id)}
className={`${smallButtonClass} text-red-700 dark:text-red-400`}
disabled={readOnly && client.hand_edited}
title={
readOnly && client.hand_edited
? DECLARED_CLIENT_NOTE
: undefined
}
className={`${smallButtonClass} text-red-700 disabled:opacity-50 dark:text-red-400`}
>
Delete
</button>
+4 -1
View File
@@ -6,6 +6,7 @@ import { defaultGroupId } from "@/lib/defaultGroup";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
prefixes: ClientPrefix[];
@@ -19,6 +20,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
const [validation, setValidation] = useState<string | null>(null);
const dirty = isDirty(state);
const fallbackGroupId = defaultGroupId(groups);
const readOnly = useReadOnlyConfig();
const save = () => {
const problem = firstProblem(state.rows);
@@ -105,7 +107,8 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
<button
type="button"
onClick={save}
disabled={!dirty || mutation.isPending}
disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
Save prefixes
@@ -5,6 +5,7 @@ import type { Blocklist } from "@/lib/types";
import { sameSet, toggleSource } from "./sourceSet";
import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
groupId: number;
@@ -16,6 +17,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
const sources = useQuery(groupSourcesQuery(groupId));
const mutation = useMutation(groupSourcesPutMutation(queryClient));
const [selected, setSelected] = useState<number[] | null>(null);
const readOnly = useReadOnlyConfig();
if (sources.isPending) {
return (
@@ -58,7 +60,8 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
<div className="mt-3 flex gap-2">
<button
type="button"
disabled={!dirty || mutation.isPending}
disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onClick={() =>
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
}
+23 -7
View File
@@ -12,6 +12,7 @@ import GroupSourcesEditor from "./GroupSourcesEditor";
import InlineError from "@/lib/InlineError";
import { focusRing, primaryButtonClass, smallButtonClass, smallInputClass } from "@/ui/classes";
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
@@ -23,6 +24,7 @@ export default function GroupsPage() {
const queryClient = useQueryClient();
const createMutation = useMutation(groupCreateMutation(queryClient));
const [newName, setNewName] = useState("");
const readOnly = useReadOnlyConfig();
return (
<section>
@@ -44,9 +46,15 @@ export default function GroupsPage() {
type="text"
value={newName}
onChange={(event) => setNewName(event.target.value)}
disabled={readOnly}
className={smallInputClass}
/>
<button type="submit" disabled={createMutation.isPending} className={primaryButtonClass}>
<button
type="submit"
disabled={createMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
Create
</button>
</form>
@@ -69,6 +77,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
const [confirming, setConfirming] = useState(false);
const [expanded, setExpanded] = useState(false);
const isDefault = group.id === DEFAULT_GROUP_ID;
const readOnly = useReadOnlyConfig();
const lockNote = isDefault ? DEFAULT_GROUP_NOTE : readOnly ? READ_ONLY_HINT : undefined;
return (
<li className="rounded border border-zinc-200 p-4 dark:border-zinc-700">
@@ -94,7 +104,12 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
className={smallInputClass}
autoFocus
/>
<button type="submit" disabled={updateMutation.isPending} className={groupButtonClass}>
<button
type="submit"
disabled={updateMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={groupButtonClass}
>
Save
</button>
<button
@@ -115,7 +130,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
<input
type="checkbox"
checked={group.safe_search}
disabled={updateMutation.isPending}
disabled={updateMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={focusRing}
onChange={(event) =>
updateMutation.mutate({
@@ -138,8 +154,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
{!renaming && (
<button
type="button"
disabled={isDefault}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
disabled={isDefault || readOnly}
title={lockNote}
onClick={() => {
setName(group.name);
setRenaming(true);
@@ -168,8 +184,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
) : (
<button
type="button"
disabled={isDefault}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
disabled={isDefault || readOnly}
title={lockNote}
onClick={() => setConfirming(true)}
className={`${groupButtonClass} text-red-700 dark:text-red-400`}
>
+31 -6
View File
@@ -17,18 +17,21 @@ import {
rowButtonClass,
tableWrapClass,
} from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
function RecordForm({
initial,
busy,
readOnly,
error,
onSubmit,
onCancel,
}: {
initial?: LocalRecord;
busy: boolean;
readOnly: boolean;
error: unknown;
onSubmit: (input: LocalRecordInput) => void;
onCancel: () => void;
@@ -109,7 +112,12 @@ function RecordForm({
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={busy} className={largePrimaryButtonClass}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass}
>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} className={largeButtonClass}>
@@ -132,18 +140,31 @@ export default function RecordsTab() {
remove: localRecordDeleteMutation,
confirmDelete: (record) => `Delete record "${record.name}"?`,
});
const readOnly = useReadOnlyConfig();
return (
<div>
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-zinc-500">Answers served directly for LAN names. Changes apply live.</p>
<button type="button" onClick={() => openForm({ mode: "create" })} className={largePrimaryButtonClass}>
<button
type="button"
onClick={() => openForm({ mode: "create" })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass}
>
Add record
</button>
</div>
<InlineError error={remove.error} />
{form?.mode === "create" && (
<RecordForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
<RecordForm
busy={create.isPending}
readOnly={readOnly}
error={create.error}
onSubmit={onSubmit}
onCancel={closeForm}
/>
)}
<div className={tableWrapClass}>
<table className="w-full text-left text-sm">
@@ -181,6 +202,7 @@ export default function RecordsTab() {
<RecordForm
initial={record}
busy={update.isPending}
readOnly={readOnly}
error={update.error}
onSubmit={onSubmit}
onCancel={closeForm}
@@ -196,15 +218,18 @@ export default function RecordsTab() {
<button
type="button"
onClick={() => openForm({ mode: "edit", entity: record })}
className={rowButtonClass}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} disabled:opacity-50`}
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(record)}
disabled={remove.isPending}
className={`${rowButtonClass} text-red-600 dark:text-red-400`}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`}
>
Delete
</button>
+31 -6
View File
@@ -17,16 +17,19 @@ import {
rowButtonClass,
tableWrapClass,
} from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
function ZoneForm({
initial,
busy,
readOnly,
error,
onSubmit,
onCancel,
}: {
initial?: ForwardZone;
busy: boolean;
readOnly: boolean;
error: unknown;
onSubmit: (input: ForwardZoneInput) => void;
onCancel: () => void;
@@ -70,7 +73,12 @@ function ZoneForm({
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={busy} className={largePrimaryButtonClass}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass}
>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} className={largeButtonClass}>
@@ -93,6 +101,7 @@ export default function ZonesTab() {
remove: forwardZoneDeleteMutation,
confirmDelete: (zone) => `Delete forward zone "${zone.zone}"?`,
});
const readOnly = useReadOnlyConfig();
return (
<div>
@@ -100,13 +109,25 @@ export default function ZonesTab() {
<p className="text-sm text-zinc-500">
Names under these zones go to their own resolver. Changes apply live.
</p>
<button type="button" onClick={() => openForm({ mode: "create" })} className={largePrimaryButtonClass}>
<button
type="button"
onClick={() => openForm({ mode: "create" })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass}
>
Add zone
</button>
</div>
<InlineError error={remove.error} />
{form?.mode === "create" && (
<ZoneForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
<ZoneForm
busy={create.isPending}
readOnly={readOnly}
error={create.error}
onSubmit={onSubmit}
onCancel={closeForm}
/>
)}
<div className={tableWrapClass}>
<table className="w-full text-left text-sm">
@@ -138,6 +159,7 @@ export default function ZonesTab() {
<ZoneForm
initial={zone}
busy={update.isPending}
readOnly={readOnly}
error={update.error}
onSubmit={onSubmit}
onCancel={closeForm}
@@ -151,15 +173,18 @@ export default function ZonesTab() {
<button
type="button"
onClick={() => openForm({ mode: "edit", entity: zone })}
className={rowButtonClass}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} disabled:opacity-50`}
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(zone)}
disabled={remove.isPending}
className={`${rowButtonClass} text-red-600 dark:text-red-400`}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`}
>
Delete
</button>
+10 -2
View File
@@ -6,6 +6,7 @@ import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from
import type { Rule, RuleAction, RuleKind } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
export default function RulesPage() {
const queryClient = useQueryClient();
@@ -19,6 +20,7 @@ export default function RulesPage() {
const [kind, setKind] = useState<RuleKind>("exact");
const [action, setAction] = useState<RuleAction>("block");
const [groupId, setGroupId] = useState(() => defaultGroupId(groups));
const readOnly = useReadOnlyConfig();
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -77,7 +79,8 @@ export default function RulesPage() {
<button
type="button"
onClick={() => deleteRule(rule)}
disabled={remove.isPending}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass}
>
Delete
@@ -154,7 +157,12 @@ export default function RulesPage() {
</select>
</div>
</div>
<button type="submit" disabled={create.isPending} className={primaryButtonClass}>
<button
type="submit"
disabled={create.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
{create.isPending ? "Creating…" : "Create rule"}
</button>
<InlineError error={create.error} />
@@ -0,0 +1,19 @@
import { useAuthority } from "./authority";
/**
* File authority is a standing condition, not an event, so this banner has no
* dismiss button: it stays up for as long as the process runs from a file.
*/
export default function ReadOnlyConfigBanner() {
const authority = useAuthority();
if (authority?.mode !== "managed_file") return null;
return (
<div
role="status"
className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100"
>
Configuration is managed by <code className="font-mono">{authority.path}</code>. Edit the file and restart
nxdns to change it; the server rejects edits made here.
</div>
);
}
+5 -2
View File
@@ -5,6 +5,7 @@ import { settingsPutMutation, settingsQuery } from "@/lib/queries";
import { buildSettingsPatch } from "@/lib/settingsDiff";
import type { Settings, SettingsPatch } from "@/lib/types";
import { raiseRestartBanner } from "./restartBanner";
import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority";
import { focusRing } from "@/ui/classes";
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */
@@ -242,7 +243,8 @@ export default function SettingsPage() {
);
});
const patch = buildSettingsPatch(baseline, edited, password === "" ? undefined : password);
const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending;
const readOnly = useReadOnlyConfig();
const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending || readOnly;
function setField(section: keyof Settings, key: string, value: unknown): void {
setEdited((prev) => ({
@@ -273,7 +275,7 @@ export default function SettingsPage() {
Changes are validated as a whole; every setting requires a restart to take effect.
</p>
<form onSubmit={handleSubmit} className="mt-4 max-w-3xl">
<fieldset disabled={mutation.isPending} className="space-y-6">
<fieldset disabled={mutation.isPending || readOnly} className="space-y-6">
{SECTIONS.map(({ section, title, fields }) => (
<fieldset key={section} className="rounded border border-zinc-200 p-4 dark:border-zinc-800">
<legend className="px-1 text-sm font-semibold">{title}</legend>
@@ -339,6 +341,7 @@ export default function SettingsPage() {
<button
type="submit"
disabled={saveDisabled}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`rounded bg-blue-600 px-4 py-1.5 text-sm font-medium text-white ${focusRing} disabled:bg-zinc-300 disabled:text-zinc-500 dark:disabled:bg-zinc-800`}
>
{mutation.isPending ? "Saving…" : "Save"}
@@ -0,0 +1,254 @@
import { render, screen, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import type { Authority, Settings, SettingsEnvelope } from "@/lib/types";
// One file for the whole file-mode sweep: the settings envelope is the only
// discovery mechanism, so every page test needs the same stubbed envelope.
const CONFIG_PATH = "/etc/nxdns/config.zon";
const DATABASE: Authority = { mode: "database", path: null, reconciled_at: null };
const MANAGED_FILE: Authority = { mode: "managed_file", path: CONFIG_PATH, reconciled_at: 1754899200 };
function baseSettings(): Settings {
return {
upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 },
dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
blocking: { response: "zero", ttl: 300 },
cache: { size: 10000, negative_ttl_max: 300 },
web: {
enabled: true,
bind: "127.0.0.1",
port: 8080,
session_ttl_hours: 24,
api_rate_limit_per_min: 60,
api_localhost_exempt: true,
sse_max_connections_per_ip: 2,
trusted_proxies: "",
auth_enabled: true,
},
doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" },
dot_server: { enabled: false, bind: "0.0.0.0", port: 853, cert_path: "", key_path: "" },
edns: { ecs_mode: "strip" },
logging: {
level: "info",
retention_days: 30,
query_log_buffer_max: 10000,
hide_domains: false,
hide_client_ips: false,
output: "stderr",
file_path: "",
max_size_mb: 50,
max_files: 3,
},
disk: { min_free_mb: 100, warn_free_mb: 500 },
blocklist_update: { enabled: true, interval_hours: 24 },
};
}
function envelope(authority: Authority): SettingsEnvelope {
return { settings: baseSettings(), restart_required: [], authority };
}
const GROUPS = {
groups: [
{ id: 1, name: "default", safe_search: false },
{ id: 2, name: "kids", safe_search: true },
],
};
const BLOCKLISTS = {
blocklists: [
{
id: 1,
url: "https://example.com/ads.txt",
name: "Ads",
enabled: true,
is_suggested: false,
last_updated: null,
domain_count: 100,
wildcard_count: 0,
skipped_regex_count: 0,
checksum: null,
},
],
};
const RULES = {
rules: [
{
id: 1,
group_id: 1,
group: "default",
pattern: "ads.example.com",
kind: "exact",
action: "block",
created_at: 1700000000,
},
],
};
const CLIENTS = {
clients: [
{
id: 1,
ip: "192.168.1.10",
name: "laptop",
group_id: 1,
group: "default",
hand_edited: true,
first_seen: 1700000000,
last_seen: 1700003600,
},
{
id: 2,
ip: "192.168.1.11",
name: "",
group_id: 2,
group: "kids",
hand_edited: false,
first_seen: 1700000000,
last_seen: 1700007200,
},
],
};
const PREFIXES = {
client_prefixes: [{ id: 1, prefix: "192.168.1.0/24", group_id: 2, group: "kids", priority: 100 }],
};
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
const BASE: Record<string, unknown> = {
"GET /api/version": VERSION,
"GET /api/groups": GROUPS,
"GET /api/blocklists": BLOCKLISTS,
"GET /api/rules": RULES,
"GET /api/clients": CLIENTS,
"GET /api/client-prefixes": PREFIXES,
};
function stubFetch(map: Record<string, unknown>) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const key = `${init?.method ?? "GET"} ${String(input)}`;
const payload = map[key];
if (payload === undefined) {
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
}
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
}
async function renderAt(route: string, heading: string, authority: Authority) {
stubFetch({ ...BASE, "GET /api/settings": envelope(authority) });
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: [route] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
await screen.findByRole("heading", { name: heading });
}
function button(name: string): HTMLButtonElement {
return screen.getByRole("button", { name }) as HTMLButtonElement;
}
function clientRow(ip: string): HTMLElement {
const row = screen.getByText(ip).closest("tr");
if (row === null) throw new Error(`no client row for ${ip}`);
return row;
}
afterEach(() => {
vi.unstubAllGlobals();
});
test("the banner names the managed file in file mode", async () => {
await renderAt("/rules", "Rules", MANAGED_FILE);
const banner = await screen.findByText(/configuration is managed by/i);
expect(banner.textContent).toContain(CONFIG_PATH);
expect(banner.textContent).toMatch(/restart/i);
expect(banner.closest('[role="status"]')).toBeTruthy();
});
test("the banner is absent in database mode", async () => {
await renderAt("/rules", "Rules", DATABASE);
await screen.findByRole("button", { name: "Create rule" });
expect(screen.queryByText(/configuration is managed by/i)).toBeNull();
});
function kidsRow(): HTMLElement {
const row = screen.getByText("kids").closest("li");
if (row === null) throw new Error("no row for group kids");
return row;
}
test("file mode disables the Groups create and delete controls", async () => {
await renderAt("/groups", "Groups", MANAGED_FILE);
await screen.findByText(/configuration is managed by/i);
expect(button("Create").disabled).toBe(true);
expect((within(kidsRow()).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
expect((within(kidsRow()).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).disabled).toBe(true);
});
test("database mode leaves the Groups create and delete controls enabled", async () => {
await renderAt("/groups", "Groups", DATABASE);
await screen.findByRole("button", { name: "Create" });
expect(button("Create").disabled).toBe(false);
expect((within(kidsRow()).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
expect((within(kidsRow()).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).disabled).toBe(false);
});
test("file mode disables the Rules create and delete controls", async () => {
await renderAt("/rules", "Rules", MANAGED_FILE);
await screen.findByText(/configuration is managed by/i);
expect(button("Create rule").disabled).toBe(true);
expect(button("Delete").disabled).toBe(true);
});
test("database mode leaves the Rules create and delete controls enabled", async () => {
await renderAt("/rules", "Rules", DATABASE);
await screen.findByRole("button", { name: "Create rule" });
expect(button("Create rule").disabled).toBe(false);
expect(button("Delete").disabled).toBe(false);
});
test("file mode keeps delete live for an observed client and blocks it for a declared one", async () => {
await renderAt("/clients", "Clients", MANAGED_FILE);
await screen.findByText(/configuration is managed by/i);
const declared = clientRow("192.168.1.10");
const observed = clientRow("192.168.1.11");
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
expect((within(observed).getByRole("button", { name: "Edit" }) as HTMLButtonElement).disabled).toBe(true);
});
test("file mode leaves the blocklist refresh button enabled", async () => {
await renderAt("/blocklists", "Blocklists", MANAGED_FILE);
await screen.findByText(/configuration is managed by/i);
expect(button("Update now").disabled).toBe(false);
expect(button("Add source").disabled).toBe(true);
expect((screen.getByRole("checkbox", { name: "Ads enabled" }) as HTMLInputElement).disabled).toBe(true);
});
+25
View File
@@ -0,0 +1,25 @@
import { useQuery } from "@tanstack/react-query";
import { settingsQuery } from "@/lib/queries";
import type { Authority } from "@/lib/types";
/** The one-line explanation on every control file authority takes away. */
export const READ_ONLY_HINT = "Configuration is managed by a file; edit the file and restart nxdns.";
/**
* The running server's configuration authority, read from the settings
* envelope the only route that carries it. `undefined` until that query
* resolves. Every page may call this: it is the shared `["settings"]` key, so
* the shell's own subscription serves them all from cache.
*/
export function useAuthority(): Authority | undefined {
return useQuery(settingsQuery()).data?.authority;
}
/**
* True only once the server has said a file owns the configuration. While the
* mode is unknown nothing is disabled the 403 is the enforcement, this is
* the courtesy.
*/
export function useReadOnlyConfig(): boolean {
return useAuthority()?.mode === "managed_file";
}
+10 -2
View File
@@ -2,18 +2,21 @@ import { useState, type FormEvent } from "react";
import InlineError from "@/lib/InlineError";
import type { Upstream, UpstreamInput } from "@/lib/types";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
import { READ_ONLY_HINT } from "@/features/settings/authority";
const DEFAULT_PRIORITY = "100";
interface UpstreamFormProps {
initial?: Upstream;
busy: boolean;
/** File authority: the server answers 403, so the submit stays down. */
readOnly: boolean;
error: Error | null;
onSubmit: (input: UpstreamInput) => Promise<void>;
onCancel?: () => void;
}
export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel }: UpstreamFormProps) {
export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit, onCancel }: UpstreamFormProps) {
const [url, setUrl] = useState(initial?.url ?? "");
const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority));
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
@@ -97,7 +100,12 @@ export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel
Enabled
</label>
<div className="flex items-center gap-2">
<button type="submit" disabled={busy} className={primaryButtonClass}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
{initial === undefined ? "Add upstream" : "Save changes"}
</button>
{onCancel !== undefined && (
+10 -3
View File
@@ -6,6 +6,7 @@ import type { Upstream, UpstreamInput } from "@/lib/types";
import { raiseRestartBanner } from "../settings/restartBanner";
import UpstreamForm from "./UpstreamForm";
import { dangerLinkButtonClass, focusRing, linkButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
export default function UpstreamsPage() {
const queryClient = useQueryClient();
@@ -16,6 +17,7 @@ export default function UpstreamsPage() {
const save = useMutation(upstreamUpdateMutation(queryClient));
const toggle = useMutation(upstreamUpdateMutation(queryClient));
const remove = useMutation(upstreamDeleteMutation(queryClient));
const readOnly = useReadOnlyConfig();
async function submitForm(input: UpstreamInput) {
if (editing === null) {
@@ -84,7 +86,8 @@ export default function UpstreamsPage() {
type="checkbox"
aria-label={`${u.url} enabled`}
checked={u.enabled}
disabled={toggle.isPending}
disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(u)}
className={focusRing}
/>
@@ -95,14 +98,17 @@ export default function UpstreamsPage() {
<button
type="button"
onClick={() => setEditing(u)}
className={linkButtonClass}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${linkButtonClass} disabled:opacity-50`}
>
Edit
</button>
<button
type="button"
onClick={() => deleteUpstream(u)}
disabled={remove.isPending}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass}
>
Delete
@@ -121,6 +127,7 @@ export default function UpstreamsPage() {
key={editing?.id ?? "add"}
initial={editing ?? undefined}
busy={editing === null ? create.isPending : save.isPending}
readOnly={readOnly}
error={formError}
onSubmit={submitForm}
onCancel={editing === null ? undefined : () => setEditing(null)}
+10
View File
@@ -445,6 +445,11 @@ export const sample_post_pause: PauseState = {
};
export const sample_get_settings: SettingsEnvelope = {
authority: {
mode: "database",
path: null,
reconciled_at: null,
},
restart_required: [
"upstream.attempt_timeout_ms",
"upstream.read_timeout_ms",
@@ -563,6 +568,11 @@ export const sample_get_settings: SettingsEnvelope = {
};
export const sample_put_settings: SettingsEnvelope = {
authority: {
mode: "database",
path: null,
reconciled_at: null,
},
restart_required: [
"upstream.attempt_timeout_ms",
"upstream.read_timeout_ms",
+14
View File
@@ -380,9 +380,23 @@ export interface Settings {
};
}
/**
* Which configuration source the running process obeys. `path` and
* `reconciled_at` are non-null only under `managed_file`: the file the process
* loaded, and the epoch second at which it loaded it. Authority lives in the
* invocation, never in the database, so this is the only place the UI can read
* it and it rides an authenticated route, never the open ones.
*/
export interface Authority {
mode: "database" | "managed_file";
path: string | null;
reconciled_at: number | null;
}
export interface SettingsEnvelope {
settings: Settings;
restart_required: string[];
authority: Authority;
}
export interface TlsListenerPatch {
+2
View File
@@ -5,6 +5,7 @@ import { useAuth } from "@/auth/store";
import InlineError from "@/lib/InlineError";
import { versionQuery } from "@/lib/queries";
import PauseWidget from "../features/pause/PauseWidget";
import ReadOnlyConfigBanner from "../features/settings/ReadOnlyConfigBanner";
import RestartBanner from "../features/settings/RestartBanner";
import { buttonClass, focusRing } from "@/ui/classes";
@@ -112,6 +113,7 @@ export default function AppShell() {
</div>
</header>
<RestartBanner />
<ReadOnlyConfigBanner />
{drawerOpen && (
<div id="mobile-nav" className="border-b border-zinc-200 md:hidden dark:border-zinc-800">
<nav aria-label="Main" className="px-2 py-2">