milestone 20: declarative configuration for iac
This commit is contained in:
@@ -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
@@ -41,10 +41,9 @@ Do not create `/var/lib/nxdns` or `/var/log/nxdns` by hand. The unit's
|
||||
`StateDirectory` and `LogsDirectory` settings make systemd create them on first
|
||||
start, `/var/lib/nxdns` at mode 0700 owned by `nxdns`.
|
||||
|
||||
## 2. Write the seed configuration
|
||||
## 2. Write the configuration
|
||||
|
||||
nxdns starts from an empty database only if a configuration file tells it what
|
||||
to forward to. Write `/etc/nxdns/config.zon`:
|
||||
nxdns will not start with nothing to forward to. Write `/etc/nxdns/config.zon`:
|
||||
|
||||
```zon
|
||||
.{
|
||||
@@ -76,16 +75,27 @@ A good file ends with `OK: no problems found`. Exit 2 means `check` found
|
||||
something to fix and printed every problem it found. The upstream probe sends a
|
||||
real query, so this needs working DNS on the host.
|
||||
|
||||
The file seeds the database once. From the second start onwards it is ignored
|
||||
and the database is the configuration. The seed's `web.password` is hashed at
|
||||
import time and the plaintext is never stored, so once you have logged in you
|
||||
can delete the file:
|
||||
Load it into the database:
|
||||
|
||||
```sh
|
||||
nxdns import /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
The packaged unit runs `nxdns run` with no `--config`, so from here the database
|
||||
is the configuration and nothing reads the file again. `web.password` is hashed
|
||||
and the plaintext is never stored, so once you have logged in you can delete the
|
||||
file:
|
||||
|
||||
```sh
|
||||
rm /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
A kept seed is not a backup. `nxdns export` is.
|
||||
A kept file is not a backup. `nxdns export` is.
|
||||
|
||||
To keep the file as the configuration instead — converged at every start, with
|
||||
the UI refusing configuration edits — do not delete it, and add a drop-in that
|
||||
appends `--config=/etc/nxdns/config.zon` to `ExecStart`. See
|
||||
`docs/how-to/install-with-systemd.md`.
|
||||
|
||||
## 3. Start it
|
||||
|
||||
@@ -107,7 +117,7 @@ dig @<server-ip> example.com A +short
|
||||
```
|
||||
|
||||
The admin interface is on port 8080 by default; log in with the password from
|
||||
the seed file. `http://<server-ip>:8080/api/health` reports upstream
|
||||
the configuration file. `http://<server-ip>:8080/api/health` reports upstream
|
||||
availability and disk state without a login.
|
||||
|
||||
## More
|
||||
|
||||
@@ -9,6 +9,8 @@ you what asked for what.
|
||||
|
||||
- Blocklist filtering: subscribe to hosts/domain lists, plus your own allow
|
||||
and block rules with wildcard support (`*.example.com`)
|
||||
- Two configuration modes: a database the web UI edits, or a ZON file you keep
|
||||
in git and converge onto at every start
|
||||
- Per-client policy groups: different filtering for the kids' tablet and
|
||||
your workstation
|
||||
- Local DNS records and conditional forwarding for internal zones
|
||||
@@ -39,7 +41,7 @@ says what that signature does and does not prove.
|
||||
|
||||
## Quickstart (docker compose)
|
||||
|
||||
Seed a minimal configuration and start the published image. This is what the
|
||||
Write a minimal configuration and start the published image. This is what the
|
||||
first release will make possible; it does not work today, because there is no
|
||||
image in the registry to pull:
|
||||
|
||||
@@ -60,11 +62,20 @@ The compose file defaults to `:latest`; pin a version for anything you intend
|
||||
to keep running. To run it before a release exists, build the image yourself and
|
||||
name it — `NXDNS_IMAGE=nxdns docker compose up -d` — as
|
||||
[docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md)
|
||||
describes. DNS is on port 53, the web UI on <http://localhost:8080>. The config
|
||||
file seeds the database on first boot only; from then on the database is the
|
||||
truth and changes go through the UI, the API, or `nxdns export` /
|
||||
`nxdns import`. Full install instructions, including the systemd path and
|
||||
the Pi 5 recipe, are in
|
||||
describes. DNS is on port 53, the web UI on <http://localhost:8080>.
|
||||
|
||||
The compose file runs `nxdns run --config=/etc/nxdns/config.zon`, which makes
|
||||
that file the configuration: every start reconciles the database onto it, and
|
||||
the UI refuses configuration edits. Edit the file and restart to change
|
||||
anything. Drop the `command:` line to run bare `nxdns run` instead, where the
|
||||
database is the configuration and changes go through the UI, the API, or
|
||||
`nxdns export` / `nxdns import` — the packaged systemd unit does that. Which
|
||||
mode is live is printed at every start (`authority: database` /
|
||||
`authority: file (<path>)`); see
|
||||
[docs/explanation/configuration-model.md](docs/explanation/configuration-model.md).
|
||||
|
||||
Full install instructions, including the systemd path and the Pi 5 recipe, are
|
||||
in
|
||||
[docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) and
|
||||
[docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md).
|
||||
|
||||
|
||||
@@ -6,10 +6,17 @@ services:
|
||||
# NXDNS_IMAGE=nxdns.
|
||||
image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-latest}}
|
||||
restart: unless-stopped
|
||||
# First boot needs ./etc-nxdns/config.zon with a `default` group and at
|
||||
# least one enabled upstream, or the container exits with code 2. The file
|
||||
# seeds the database once; after that the database is the truth and the
|
||||
# file is ignored.
|
||||
# `run --config` makes the file the sole source of configuration: nxdns
|
||||
# reconciles the database onto ./etc-nxdns/config.zon at every start, and
|
||||
# rejects configuration writes from the admin UI. Edit the file and restart
|
||||
# the container to change anything. The file needs a `default` group and at
|
||||
# least one enabled upstream, or the container exits with code 2. A
|
||||
# recreated nxdns-data volume rebuilds itself from the file on next start.
|
||||
#
|
||||
# Drop this line to let the database be the truth instead, and load a first
|
||||
# configuration once with:
|
||||
# docker compose run --rm nxdns import /etc/nxdns/config.zon
|
||||
command: ["run", "--config=/etc/nxdns/config.zon"]
|
||||
volumes:
|
||||
- ./etc-nxdns:/etc/nxdns:ro
|
||||
- nxdns-data:/var/lib/nxdns
|
||||
|
||||
@@ -16,6 +16,11 @@ StateDirectoryMode=0700
|
||||
LogsDirectory=nxdns
|
||||
ConfigurationDirectory=nxdns
|
||||
|
||||
# ConfigurationDirectory creates /etc/nxdns owned by the service user. nxdns
|
||||
# never writes there in either authority mode, and in file mode that directory
|
||||
# holds the source of truth, so deny the write outright rather than rely on it.
|
||||
ReadOnlyPaths=/etc/nxdns
|
||||
|
||||
# Port 53 (and 443/853 when the DoH/DoT listeners are enabled).
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
@@ -44,6 +49,9 @@ SystemCallArchitectures=native
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
# Exit 2 is a configuration fault and 64 is a usage error. Neither clears on a
|
||||
# retry, so a restart loop only buries the diagnostics already in the journal.
|
||||
RestartPreventExitStatus=2 64
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ are inspectable.
|
||||
- [explanation/architecture.md](explanation/architecture.md) — the module map
|
||||
and the design it comes from.
|
||||
- [explanation/configuration-model.md](explanation/configuration-model.md) — why
|
||||
the file seeds the database once and the database is the truth afterwards.
|
||||
there are two authority modes, how each one is selected, and what each is for.
|
||||
- [explanation/performance-and-testing.md](explanation/performance-and-testing.md)
|
||||
— why the targets exist, why CI does not gate on them, and what the hermetic
|
||||
tests do and do not prove.
|
||||
|
||||
@@ -35,7 +35,7 @@ Directories:
|
||||
| `src/upstream/` | Upstream resolution: shared vocabulary and the `Client` interface (`transport.zig`), DoH client (RFC 8484), DoT client (RFC 7858), per-endpoint health and backoff (`health.zig`), and `pool.zig` — priority-ordered failover that is itself a `transport.Client`, so the handler sees one interface. |
|
||||
| `src/server/` | The serving side: UDP/TCP/DoH/DoT listeners, `handler.zig` (the whole query pipeline), `cert_store.zig` (refcounted TLS cert holder), `rate_limiter.zig`, `pause.zig`, `clients.zig` (client auto-materialisation), `local_tables.zig` (published local-answer tables), `query_sink.zig` (log and SSE fanout), `shutdown.zig` (SIGINT/SIGTERM into one `std.Io.Event`). |
|
||||
| `src/storage/` | SQLite ownership: `db.zig` is the only file that calls SQLite, `config_schema.zig` + `migrations.zig` for `config.db`, `querylog_schema.zig` (open-or-recreate), async query `logger.zig`, `retention.zig`, `disk_monitor.zig`, and one repository per table under `repositories/`. |
|
||||
| `src/config/` | The one configuration model (`model.zig`), the pure validator (`validate.zig`), `import.zig`/`export.zig` (ZON to and from `config.db`, byte-stable round trip), `bootstrap.zig` (first-start seeding — a policy wrapper over import). |
|
||||
| `src/config/` | The one configuration model (`model.zig`), the pure validator (`validate.zig`), `import.zig`/`export.zig` (ZON to and from `config.db`, byte-stable round trip), `loader.zig` (read/parse/validate a named file, with the shared fault mapping), `reconcile.zig` (converge the database onto a parsed config by row identity). |
|
||||
| `src/web/` | The admin HTTP layer: `server.zig` (listener), `router.zig`/`routes.zig`, one file per resource under `handlers/`, `auth.zig` (sessions), `sse.zig` (live query fanout), `static.zig` (embedded SPA), `metrics.zig` (Prometheus), `openapi.zig` (served contract), `api_limiter.zig`, `http_util.zig`. |
|
||||
| `src/platform/` | OS and TLS edges: IP address values, the `std.log` sink (`logging.zig`), `statfs.zig` (free-space query via libc), client TLS over `std.crypto.tls` (`tls_client.zig`), server TLS over vendored Mbed TLS (`tls_server.zig`). |
|
||||
|
||||
@@ -47,7 +47,7 @@ main.zig ── cli.zig ── app.zig (composition root)
|
||||
│ injects std.Io + collaborators
|
||||
┌──────────────────────┴───────────────────────┐
|
||||
│ server/ web/ upstream/ storage/ │ I/O edge
|
||||
│ platform/ config/{import,export,bootstrap} │
|
||||
│ platform/ config/{loader,reconcile,import} │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ dns/ filter/* local/* cache/ │ pure core:
|
||||
│ config/{model,validate} │ bytes in, bytes out
|
||||
@@ -162,14 +162,20 @@ working resolver over a correct-looking failure.
|
||||
Two databases with opposite contracts, in one data directory (see
|
||||
[reference/files-and-directories.md](../reference/files-and-directories.md)).
|
||||
|
||||
**`config.db` is the truth.** Its schema is versioned: `migrations.zig` holds
|
||||
**`config.db` is what the server reads.** Its schema is versioned: `migrations.zig` holds
|
||||
an ordered list of steps, step 1 being the verbatim DDL from
|
||||
`config_schema.zig`, each applied inside one transaction. `nxdns import`
|
||||
replaces the whole content atomically under `BEGIN IMMEDIATE`, so a failed
|
||||
import changes nothing; `nxdns export` renders it back as canonical ZON,
|
||||
byte-identical across round trips. A config file seeds this database exactly
|
||||
once at first start. Why it works that way is
|
||||
[configuration-model.md](configuration-model.md).
|
||||
byte-identical across round trips.
|
||||
|
||||
Which of the file and the database is *authoritative* is chosen by the
|
||||
invocation, not by state: bare `nxdns run` serves the database, and
|
||||
`nxdns run --config FILE` makes the file authoritative and reconciles the
|
||||
database onto it at every start. `reconcile.zig` is that convergence, matching
|
||||
rows by identity and writing only differences, so runtime state — blocklist
|
||||
checksums, compiled snapshots, client history — survives. Why it works that way
|
||||
is [configuration-model.md](configuration-model.md).
|
||||
|
||||
**`querylog.db` is expendable.** It is never migrated. Its schema carries a
|
||||
fingerprint derived from the DDL text, and at open, a missing, corrupt,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# The configuration model
|
||||
|
||||
nxdns is configured two ways — a ZON file and a web UI — and only one of them
|
||||
can be the truth. This page explains which, and why that choice is the one
|
||||
that leaves the fewest ways to lose an operator's work.
|
||||
can be the truth at a time. This page explains how that choice is made, what
|
||||
each mode is for, and why the design leaves the fewest ways to lose an
|
||||
operator's work.
|
||||
|
||||
For the fields themselves see
|
||||
[reference/configuration.md](../reference/configuration.md); for the commands
|
||||
@@ -10,146 +11,169 @@ and their exit codes see [reference/cli.md](../reference/cli.md).
|
||||
|
||||
## The rule
|
||||
|
||||
The database is the truth. The file is a seed.
|
||||
**Authority is the invocation.**
|
||||
|
||||
`config.db` in the data directory holds the running configuration. The ZON
|
||||
file (default `/etc/nxdns/config.zon`, overridable with `--config`) is read on
|
||||
`nxdns run` only while the database is still empty: if the file exists and the
|
||||
database holds no configuration, it is imported. Once the import has put rows
|
||||
in, the file is not opened again.
|
||||
```
|
||||
nxdns run the database is the truth
|
||||
nxdns run --config /etc/nxdns/config.zon the file is the truth
|
||||
```
|
||||
|
||||
The condition is the state of the database, not a one-shot flag. A `run` whose
|
||||
seed fails — unreadable file, parse error, failed validation, a constraint
|
||||
violation inside the import transaction — leaves the database empty, so the
|
||||
next `run` reads the file again. That is what makes fixing a typo and starting
|
||||
again work.
|
||||
That is the entire selection mechanism. There is no mode setting, no default
|
||||
file path, and nothing recorded in the database about which mode last wrote it.
|
||||
A `config.zon` that exists but that no invocation names changes nothing at all.
|
||||
|
||||
`src/config/bootstrap.zig` is that policy and nothing else — a wrapper over
|
||||
the same import path `nxdns import` uses. Three outcomes:
|
||||
Two properties fall out of that, and both were chosen on purpose.
|
||||
|
||||
- no file: the database is used as it is;
|
||||
- file present, database empty: seed it;
|
||||
- file present, database already configured: skip, without reading the file.
|
||||
**An operator can read `ExecStart` and know which authority is live.** The
|
||||
alternative — probe a well-known path, and behave differently depending on
|
||||
whether a file happens to be there — is ambient magic. It is also the exact
|
||||
class of rule that produced years of documentation lies in this project: the
|
||||
old design read the file only while the database was empty, which meant the same
|
||||
command did two different things depending on state nobody could see from the
|
||||
command line, and every page that described it eventually described it wrongly.
|
||||
|
||||
A file that exists but is unreadable, unparseable or invalid fails the start,
|
||||
with every problem printed. nxdns never falls back to silent defaults over a
|
||||
file an operator wrote — a resolver that boots "successfully" with a
|
||||
configuration nobody chose is the worst outcome available, because it looks
|
||||
like it worked.
|
||||
**A path already expresses a two-state choice, so a mode flag beside it would
|
||||
be redundant and worse.** An earlier draft had `--config-source=db|file`. A mode
|
||||
flag next to a path flag manufactures combinations that cannot mean anything —
|
||||
a path with no mode, a mode with no path — and each one then needs a pairing
|
||||
rule and a usage error to defend it. Presence-of-path has no invalid
|
||||
combinations, so there is nothing to defend.
|
||||
|
||||
## Why the database wins
|
||||
## Database mode
|
||||
|
||||
The alternative designs all lose data.
|
||||
`nxdns run`. `config.db` holds the configuration; the UI, the API and `nxdns
|
||||
import` write to it; nothing reads a file. This is the appliance: someone sets
|
||||
the box up once, and afterwards the household member who wants to unblock one
|
||||
domain clicks a button.
|
||||
|
||||
If the file were the truth, the admin UI could not write. Every change would
|
||||
be an SSH session and a restart, which defeats the reason the UI exists: the
|
||||
household member who wants to unblock one domain is not going to edit ZON.
|
||||
A fresh install in this mode starts from an empty database, which fails
|
||||
validation on its own terms — there is nowhere to forward a query to — and says
|
||||
what to do about it:
|
||||
|
||||
If both were the truth, they would disagree. The UI writes a rule; the file
|
||||
still says otherwise; the next restart either silently reverts the rule or
|
||||
silently ignores the file. Both are silent, and both destroy work someone
|
||||
intended to keep. There is no merge rule that fixes this, because the system
|
||||
cannot know which of two conflicting statements is the newer intention.
|
||||
```
|
||||
nxdns run failed: NoUsableUpstreams
|
||||
run `nxdns check` to see the configuration in full
|
||||
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
|
||||
```
|
||||
|
||||
So the file's authority ends the moment the database has content. Editing
|
||||
`config.zon` after first boot does nothing — no partial effect, no warning
|
||||
that some fields took and others did not. That is a blunt rule, and it is the
|
||||
point: the failure mode is "my edit did nothing", which is visible the first
|
||||
time you look at the UI, rather than "my edit was applied and then quietly
|
||||
undone next Tuesday".
|
||||
## File mode
|
||||
|
||||
The emptiness check is a real query over the content tables, not a flag: the
|
||||
database counts as configured when a content table holds rows, or when the
|
||||
default group has been altered. What it measures is intent, not activity, and
|
||||
the client table is where those two come apart. Clients are auto-materialised
|
||||
when they first send a query — the DNS path writes a row per device it sees —
|
||||
and such a row records what the network did, not what an operator decided. It
|
||||
carries `hand_edited = 0`, the emptiness check counts only the `hand_edited = 1`
|
||||
rows, and `export` omits them. So answering queries never turns an unconfigured
|
||||
database into a configured one; naming a device does.
|
||||
`nxdns run --config FILE`. The file is the sole declarative source, and the
|
||||
database becomes the runtime substrate: every start reads the file, validates
|
||||
it, converges the database onto it, and serves from there. Configuration writes
|
||||
through the API are refused with a 403.
|
||||
|
||||
Counting traffic here would have been a quiet trap: a server that resolved one
|
||||
name would have declared itself configured and ignored a seed file placed
|
||||
afterwards, and the operator would have had no line of output saying why.
|
||||
This is the mode for a file kept in git and pushed by Ansible. What it buys is
|
||||
that the deployed file is what is running — not "was imported once", not
|
||||
"was imported unless someone clicked something since".
|
||||
|
||||
The same distinction survives an import. `import` replaces the content in one
|
||||
transaction, which empties `clients` along with every other table, so every
|
||||
client row is saved before the wipe. The materialised ones are put back after it
|
||||
unchanged, and restoring a backup does not make the server forget the devices it
|
||||
has met.
|
||||
Three properties make it usable rather than merely correct.
|
||||
|
||||
An address the imported file names belongs to the file — the operator's
|
||||
statement wins over the discovered row — with one carve-out. First-seen and
|
||||
last-seen are not configuration: they record when a device was heard from, the
|
||||
configuration model has no field for either, and an import is not a query. So
|
||||
they follow the address rather than the row. If the database already knew that
|
||||
address, its two timestamps are carried onto the new row; only an address the
|
||||
database has never seen takes the import's clock. Without that, re-importing a
|
||||
backup would stamp every device the operator had bothered to name as though it
|
||||
had just arrived — and those are exactly the devices whose history is worth
|
||||
something.
|
||||
**It fails closed.** A file that is missing, unreadable, unparseable, oversized
|
||||
or invalid stops the start. nxdns never falls back to the database, because a
|
||||
fallback turns a deploy typo into a configuration that is silently months old
|
||||
and looks fine. That failure is exit 2, so `nxdns check --config FILE` is a real
|
||||
pre-restart gate: validate the pushed file in the handler, and a typo is a
|
||||
failed deploy at noon rather than a dead resolver at the next power cut.
|
||||
|
||||
**It converges rather than replaces.** Reconciling matches rows by identity and
|
||||
writes only what differs. A source whose URL has not changed keeps its row id,
|
||||
its checksum, its counters and its compiled blocklist files — so a restart in
|
||||
file mode downloads nothing, which is the difference between a design that is
|
||||
tolerable to restart and one that costs three minutes and 100 MB every time.
|
||||
|
||||
**An unchanged file writes nothing at all.** Not "writes the same bytes" —
|
||||
performs zero write statements, and reports it:
|
||||
|
||||
```
|
||||
reconciled '/etc/nxdns/config.zon': no changes
|
||||
```
|
||||
|
||||
That matters beyond elegance. A box whose SD card is full of query log can still
|
||||
restart in file mode, because a no-op reconcile needs no write-ahead-log
|
||||
headroom.
|
||||
|
||||
**Converged at every boot is not a lock between boots.** Nothing stops `nxdns
|
||||
import` or a `runtime action` route from moving the database while the server
|
||||
runs. The contract is that the next start puts it back, and says what it
|
||||
corrected.
|
||||
|
||||
## What the file cannot take away
|
||||
|
||||
The file is authoritative over configuration. It is not authoritative over
|
||||
things it has no vocabulary for, and reconciling has to preserve those or the
|
||||
mode is unusable.
|
||||
|
||||
- **Blocklist download state.** Checksums, fetch timestamps and domain counts
|
||||
belong to the network, not the operator. They survive on every matched row.
|
||||
- **Client history.** Devices nxdns saw on the wire are kept whole. Naming one
|
||||
in the file promotes that row in place — it keeps its first-seen and
|
||||
last-seen and its row id, and counts as an update rather than a delete and an
|
||||
insert.
|
||||
- **Devices whose group is un-declared.** Remove a group from the file and the
|
||||
observed clients assigned to it move to `default`. The operator un-declared
|
||||
the group, not the devices.
|
||||
- **The password, when the file does not mention it.** See
|
||||
[the password](#the-password).
|
||||
|
||||
The one thing identity cannot survive is a change to identity itself. Edit a
|
||||
source's URL and the engine sees one row gone and one row arrived: new id, fresh
|
||||
download, and the old compiled files swept. That is consistent — artifacts are
|
||||
keyed by row id — and it is why the CLI asks for `--allow-delete` when a diff
|
||||
deletes anything.
|
||||
|
||||
## Why the database is the substrate in both modes
|
||||
|
||||
Even in file mode the database is where the server reads its effective
|
||||
configuration from. That is not a leftover; it is what lets one read path serve
|
||||
both modes, and it is what makes the runtime state above have somewhere to live.
|
||||
|
||||
If the file were read directly on every query path there would be no place to
|
||||
keep a checksum, and no way for the UI to show anything. If both file and
|
||||
database were authoritative there would be a two-way merge, and a merge cannot
|
||||
work here: when the UI writes a rule and the file still says otherwise, nothing
|
||||
in the system knows which of two statements is the newer intention. Every
|
||||
resolution silently destroys work someone meant to keep.
|
||||
|
||||
So the modes are exclusive, and the failure mode of each is loud. In database
|
||||
mode, editing the file does nothing, which you find out the first time you look
|
||||
at the UI. In file mode, the UI refuses the edit to your face with a message
|
||||
naming the file to edit instead.
|
||||
|
||||
## The round trip
|
||||
|
||||
Losing the file as an editing surface would be a real loss — text is
|
||||
diffable, reviewable and easy to back up — so the file is kept as a
|
||||
*rendering* of the database rather than a rival to it. That is what
|
||||
`export`/`import` are for:
|
||||
The file stays useful as an editing surface in both modes — text is diffable,
|
||||
reviewable and easy to back up — because `export` renders the database into the
|
||||
same shape `import` and file mode read:
|
||||
|
||||
```
|
||||
nxdns export → canonical ZON → edit → nxdns import --force → config.db
|
||||
nxdns export → canonical ZON → edit → nxdns import → config.db
|
||||
```
|
||||
|
||||
`nxdns export` renders the database as canonical ZON: a fixed two-line
|
||||
header, every default emitted, deterministic ordering from the model's field
|
||||
order and the repositories' `ORDER BY` clauses, and no timestamps or hostnames
|
||||
anywhere. Runtime columns — first-seen, last-seen, per-source counters — are
|
||||
absent from the configuration model on purpose, so two exports taken from a
|
||||
live, busy server are identical. The round trip `export → import → export` is
|
||||
byte-identical, and a test asserts it.
|
||||
`nxdns export` writes canonical ZON: a fixed two-line header, every default
|
||||
emitted, deterministic ordering from the model's field order and the
|
||||
repositories' `ORDER BY` clauses, and no timestamps or hostnames anywhere.
|
||||
Runtime columns are absent from the configuration model on purpose, so two
|
||||
exports taken from a live, busy server are identical. `export → import →
|
||||
export` is byte-identical, and a test asserts it.
|
||||
|
||||
Byte-stability is not cosmetic. It is what makes an exported file usable in
|
||||
version control and what makes a diff of two exports mean something: any
|
||||
difference is a configuration change, never noise from when the export ran.
|
||||
|
||||
`nxdns import` replaces the whole database content in one transaction. Not a
|
||||
merge, not a patch: delete every content table in foreign-key-safe order, then
|
||||
insert what the file says — with the one exception described above. Every client
|
||||
row is lifted out first. The materialised ones are put back rather than
|
||||
recreated from a file that never held them, and the observed timestamps of an
|
||||
address the file *does* name are merged onto its new row. A client the file
|
||||
leaves out is gone, history included: nothing puts a `hand_edited = 1` row back.
|
||||
A failed import — bad syntax, failed
|
||||
validation, a constraint violation halfway through — leaves the database exactly
|
||||
as it was, because everything happens inside a single `BEGIN IMMEDIATE`.
|
||||
The output is also identical in both modes — nothing marks a file as coming
|
||||
from a file-mode box. That is deliberate, because it is what makes export the
|
||||
adoption tool: the file you check is the file you deploy, byte for byte. The
|
||||
label an operator wants is in the unit file, where they put it.
|
||||
|
||||
Without `--force`, import refuses a database that already holds configuration.
|
||||
That
|
||||
check runs *inside* the transaction, after the lock is taken, so it cannot be
|
||||
raced by a concurrent write. The effect is that a plain `import` can never
|
||||
clobber a configured server by accident, and clobbering it deliberately takes
|
||||
one visible extra word on the command line. See
|
||||
[how-to/back-up-and-restore.md](../how-to/back-up-and-restore.md).
|
||||
|
||||
## One model, three surfaces
|
||||
|
||||
`src/config/model.zig` defines exactly one `Config` type. The ZON parser
|
||||
produces it, the database reader produces it, the validator consumes it, the
|
||||
composition root consumes it, and the settings API derives its key list and
|
||||
its patch struct from its type information rather than mirroring the fields by
|
||||
hand. Adding a field in one place therefore cannot leave the other surfaces
|
||||
behind; the alternative — a file schema, a DB schema and an API schema kept in
|
||||
step by discipline — is the standard way configuration systems rot.
|
||||
|
||||
The model has two shapes of field, and the difference is structural rather
|
||||
than stylistic. Struct-typed fields are scalar sections (`dns`, `cache`,
|
||||
`web`, …) and live in a single key/value `settings` table as
|
||||
`section.field` text rows. Slice-typed fields are collections (groups,
|
||||
upstreams, clients, rules, local records, forward zones, …) and each gets its
|
||||
own table with foreign keys. `toSettings` and `fromSettings` are the two
|
||||
halves of the scalar bridge, generated by an `inline for` over the model, so
|
||||
the key list is a consequence of the type rather than a second list to
|
||||
maintain.
|
||||
Reconciling the same file twice produces a byte-identical database — ids,
|
||||
checksums, `created_at`, the password hash, the whole settings table — including
|
||||
when the file is written in a non-canonical but equivalent form, such as
|
||||
`FD00:0:0:0:0:0:0:1` for an address stored as `fd00::1`. Anything that churns
|
||||
under an unchanged file is a bug in the engine by definition. That single
|
||||
invariant is what forces most of the design above: matching on canonical forms,
|
||||
writing only on difference, treating duplicate rule tuples as a multiset, and
|
||||
verifying a password rather than re-hashing it.
|
||||
|
||||
## Unknown keys, and why the asymmetry is deliberate
|
||||
|
||||
@@ -157,52 +181,65 @@ An unknown key in the **database** is warned about and ignored. An unknown key
|
||||
in the **file** is a hard error.
|
||||
|
||||
They are different situations. A settings row the running binary does not
|
||||
recognise is almost always an older binary reading a database written by a
|
||||
newer one — a downgrade, or a rollback after a bad upgrade. Refusing to start
|
||||
there would mean a downgrade bricks the config database, and the operator
|
||||
would have to hand-edit SQLite to recover. Warning and ignoring means the
|
||||
downgrade works, the unknown setting sits inert, and the upgrade back picks it
|
||||
up again.
|
||||
recognise is almost always an older binary reading a database written by a newer
|
||||
one — a downgrade, or a rollback after a bad upgrade. Refusing to start there
|
||||
would mean a downgrade bricks the config database, and the operator would have
|
||||
to hand-edit SQLite to recover. Warning and ignoring means the downgrade works,
|
||||
the unknown setting sits inert, and the upgrade back picks it up again.
|
||||
|
||||
A key in a file, by contrast, is something a human just typed. The likeliest
|
||||
cause is a typo, and the second likeliest is a field that no longer exists.
|
||||
Silently ignoring it would mean the setting the operator believes they applied
|
||||
was never applied — exactly the silent-divergence failure the whole model is
|
||||
built to avoid. So the ZON parser rejects unknown fields with a line and
|
||||
column.
|
||||
built to avoid. So the ZON parser rejects unknown fields with a line and column.
|
||||
|
||||
The tolerance has a boundary worth stating plainly: it covers unknown *keys*,
|
||||
not unparseable *values*. A known key whose stored text does not decode into
|
||||
its type is an error, not a warning.
|
||||
not unparseable *values*. A known key whose stored text does not decode into its
|
||||
type is an error, not a warning.
|
||||
|
||||
## The password
|
||||
|
||||
`web.password` is a write-only input. It is never a stored value.
|
||||
`web.password` is a write-only input. It is never a stored value. A non-empty
|
||||
one is hashed with argon2id (PHC encoding, OWASP argon2id parameters) into
|
||||
`web.password_hash`, and the plaintext is cleared before anything is written.
|
||||
There is no settings row that can hold it: the model skips `web.password` in
|
||||
both directions of the settings bridge, so the plaintext has nowhere to go even
|
||||
by accident.
|
||||
|
||||
At import time, a non-empty `web.password` is hashed with argon2id (PHC
|
||||
encoding, OWASP argon2id parameters) into `web.password_hash`, and the
|
||||
plaintext field is cleared before anything is written. There is no settings
|
||||
row that can hold it: the model explicitly skips `web.password` in both
|
||||
directions of the settings bridge, so the plaintext has nowhere to go even by
|
||||
accident. `nxdns export` always writes `.password = ""` and carries the hash
|
||||
instead — which is also what makes the round trip stable, since an export that
|
||||
tried to reproduce a plaintext it never had could not be byte-identical.
|
||||
Both fields are optional, and this is the one deliberate carve-out from
|
||||
file-as-sole-truth: **a file that mentions neither leaves the stored hash
|
||||
alone.**
|
||||
|
||||
Setting both `password` and `password_hash` in one file is an error rather
|
||||
than a precedence rule. The two say different things about what the password
|
||||
is, and picking a winner would mean the operator's other statement was
|
||||
silently discarded. The file has to say one thing.
|
||||
The reason is a trap the design walked into once. An export carries the full PHC
|
||||
string, which is long and ugly, and an operator committing that file to git will
|
||||
sooner or later delete the line — meaning "keep the current password". If
|
||||
absence meant "no password", that edit would reconcile an empty hash over the
|
||||
stored one and open the admin UI to the entire LAN, silently, because
|
||||
authentication is on exactly when the hash is non-empty. Silence has to mean
|
||||
keep. Disabling authentication takes the explicit `password_hash = ""`.
|
||||
|
||||
The practical shape of a password change is therefore export, set `.password`
|
||||
to the new value, clear `.password_hash`, import with `--force`. The procedure
|
||||
is in
|
||||
[how-to/set-up-admin-authentication.md](../how-to/set-up-admin-authentication.md).
|
||||
The other end of the same problem is `password = ""`. Hashing the empty string
|
||||
produces a perfectly valid hash, so authentication would be *on* — while the
|
||||
login handler refuses every empty password, so it could never be satisfied.
|
||||
Auth on and unreachable is worse than either alternative, so that file is
|
||||
refused at validation with a diagnostic naming the remedy.
|
||||
|
||||
A plaintext password that has not changed is verified against the stored hash
|
||||
and kept rather than re-hashed. That is byte-stability, not a saving:
|
||||
verification recomputes the same argon2id function with the stored salt and
|
||||
costs exactly what hashing costs. Hashing unconditionally would generate a fresh
|
||||
salt on every start and break the invariant above.
|
||||
|
||||
Export's canonical form is therefore `password = null` beside the stored
|
||||
`password_hash`. Writing an empty *string* there instead would make every export
|
||||
carry a present-but-empty password next to a hash — tripping the both-set rule
|
||||
on re-import, so export's own output would fail export's own contract.
|
||||
|
||||
## What is not configuration
|
||||
|
||||
Storage paths are process arguments, not configuration fields: `--data-dir`,
|
||||
`--config`, `--web-dev`. They cannot live in the file, because the file is
|
||||
found by way of them — a path that told you where to find the thing that told
|
||||
you the path would be circular. They are also the settings a supervisor
|
||||
(systemd, Docker) owns rather than the operator's policy about DNS. See
|
||||
`--config`, `--web-dev`. They cannot live in the file, because the file is found
|
||||
by way of them — a path that told you where to find the thing that told you the
|
||||
path would be circular. They are also the settings a supervisor (systemd,
|
||||
Docker) owns rather than the operator's policy about DNS. See
|
||||
[reference/files-and-directories.md](../reference/files-and-directories.md).
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
# Back up and restore
|
||||
|
||||
The configuration database is the only state worth keeping. `nxdns export`
|
||||
writes it out as a ZON file and `nxdns import` writes one back. The query log is
|
||||
deliberately not part of a backup: it is expendable history, and if it is
|
||||
missing it gets recreated empty.
|
||||
The configuration is the only state worth keeping. `nxdns export` writes it out
|
||||
as a ZON file and `nxdns import` writes one back. The query log is deliberately
|
||||
not part of a backup: it is expendable history, and if it is missing it gets
|
||||
recreated empty.
|
||||
|
||||
**Which authority the service runs under decides what the backup *is*.** Check
|
||||
the start log:
|
||||
|
||||
- `authority: database` — `config.db` holds the configuration. Back it up with
|
||||
`nxdns export`, and restore with `nxdns import` or by replacing the database
|
||||
file.
|
||||
- `authority: file (<path>)` — that file holds the configuration, and it is
|
||||
already a text file you can keep in git. **The file is the backup.** Restoring
|
||||
means putting the file back and restarting; the database rebuilds itself from
|
||||
it. `config.db` is a cache of the file in this mode, not the thing to preserve.
|
||||
|
||||
The rest of this page covers database mode unless it says otherwise.
|
||||
|
||||
The commands below use the scratch lab from
|
||||
[enable DoH and DoT](enable-doh-and-dot.md), data directory
|
||||
@@ -40,13 +53,16 @@ grep password /tmp/nxdns-lab/backup.zon
|
||||
```
|
||||
|
||||
```
|
||||
.password = "",
|
||||
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$Gh+zg9xke6BqSVOiouRbqG50+Bs8ZGcXA6oKgs7lrKg$crTNMu5OI8yKBkp31r4+Y1OUQmLiAlH/qvsIxjBQRq4",
|
||||
.password = null,
|
||||
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$hOjjnTrTZ6kU8XrwQuJ7ZFC3B5LumaB4hRe7kBbzJ6Q$YsC2rCTDKv96eEwPhs+D6vbDliLogEZph8EkagKSuy8",
|
||||
```
|
||||
|
||||
Treat backups as secrets. `.password` is always exported as `""` — the
|
||||
Treat backups as secrets. `.password` is always exported as `null` — the
|
||||
plaintext is never stored anywhere — so the file re-imports without anyone
|
||||
knowing the password.
|
||||
knowing the password. `null` and `""` are different statements here: `null`
|
||||
means the file says nothing about the password, while an empty `password_hash`
|
||||
would disable authentication. See
|
||||
[Password and hash](../reference/configuration.md#password-and-hash).
|
||||
|
||||
Without `--out` the export goes to stdout, where the file mode is your
|
||||
redirect's problem:
|
||||
@@ -57,7 +73,7 @@ nxdns export --data-dir /tmp/nxdns-lab/data | head -10
|
||||
|
||||
```
|
||||
// nxdns configuration
|
||||
// generated by `nxdns export` — the database is the source of truth
|
||||
// generated by `nxdns export` from the running configuration
|
||||
.{
|
||||
.upstream = .{
|
||||
.attempt_timeout_ms = 2500,
|
||||
@@ -75,8 +91,8 @@ exports taken minutes apart differ.
|
||||
|
||||
## Restore onto a fresh data directory
|
||||
|
||||
This is the normal restore: new machine, new disk, empty data directory. No
|
||||
`--force`, because there is nothing to overwrite.
|
||||
This is the normal restore: new machine, new disk, empty data directory.
|
||||
Nothing to overwrite, so nothing to authorise.
|
||||
|
||||
```sh
|
||||
nxdns import /tmp/nxdns-lab/backup.zon --data-dir /tmp/nxdns-lab/data-restored
|
||||
@@ -92,64 +108,95 @@ before writing to it.
|
||||
|
||||
## Restore over an existing database
|
||||
|
||||
`import` refuses a database that already holds configuration, so a plain
|
||||
`import` can never clobber a configured server by accident:
|
||||
|
||||
```sh
|
||||
nxdns import /tmp/nxdns-lab/backup.zon --data-dir /tmp/nxdns-lab/data
|
||||
```
|
||||
|
||||
```
|
||||
import failed: DatabaseNotEmpty
|
||||
```
|
||||
|
||||
That exits 2. Say `--force` when replacing is what you mean:
|
||||
|
||||
```sh
|
||||
nxdns import /tmp/nxdns-lab/backup.zon --force --data-dir /tmp/nxdns-lab/data
|
||||
```
|
||||
|
||||
```
|
||||
imported /tmp/nxdns-lab/backup.zon
|
||||
```
|
||||
|
||||
Stop the server first. `import` replaces the whole configuration underneath a
|
||||
process that has already read it, and a running server will not notice.
|
||||
|
||||
What `--force` does to the client list is worth knowing before you restore an
|
||||
old backup. A client the backup does not name is removed, and its first-seen
|
||||
and last-seen go with it — restoring a month-old file drops the devices you
|
||||
named since. Devices the server discovered from traffic are kept, and a device
|
||||
the backup does name keeps the first-seen and last-seen the database already
|
||||
held, so a restore does not restamp your whole network as newly arrived.
|
||||
|
||||
On a real install that is the systemd unit:
|
||||
**Stop the server first.** `import` rewrites configuration underneath a process
|
||||
that read it at startup, and a running server picks up only part of it —
|
||||
filtering follows the new rows at the next reload, while upstreams, listeners
|
||||
and settings stay at their boot values until a restart.
|
||||
|
||||
```sh
|
||||
systemctl stop nxdns
|
||||
nxdns import /var/backups/nxdns-config.zon --force
|
||||
nxdns import /var/backups/nxdns-config.zon
|
||||
systemctl start nxdns
|
||||
```
|
||||
|
||||
**Not verified on this host.** Those three lines are the only commands on this
|
||||
page that were not run: this machine has no installed nxdns systemd unit
|
||||
(`systemctl status nxdns` answers `Unit nxdns.service could not be found.`) and
|
||||
`systemctl stop`/`start` need root. The lab equivalent below was run, and it
|
||||
exercises the same stop-import-start sequence. In the lab the server is a
|
||||
foreground `nxdns run`, so stopping it is Ctrl-C in its own terminal:
|
||||
`import` converges the database onto the file. Rows the file still names are
|
||||
matched and updated in place; rows it no longer names are deleted. That last
|
||||
part is what a restore of an old backup does to everything added since, so it
|
||||
takes a flag:
|
||||
|
||||
```sh
|
||||
nxdns export --data-dir /tmp/nxdns-lab/data --out /tmp/nxdns-lab/pre-restore.zon
|
||||
# Ctrl-C the `nxdns run` terminal, or `kill` its pid from another shell
|
||||
nxdns import /tmp/nxdns-lab/pre-restore.zon --force --data-dir /tmp/nxdns-lab/data
|
||||
nxdns run --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon
|
||||
nxdns import /tmp/nxdns-lab/old-backup.zon --data-dir /tmp/nxdns-lab/data
|
||||
```
|
||||
|
||||
```
|
||||
wrote /tmp/nxdns-lab/pre-restore.zon
|
||||
imported /tmp/nxdns-lab/pre-restore.zon
|
||||
FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it
|
||||
import failed: DestructiveImport
|
||||
```
|
||||
|
||||
That exits 2 and rolls the transaction back, so nothing is half-applied. The
|
||||
message names every table that would lose rows, which is usually enough to tell
|
||||
an intended restore from the wrong file. Say `--allow-delete` when deleting is
|
||||
what you mean:
|
||||
|
||||
```sh
|
||||
nxdns import /tmp/nxdns-lab/old-backup.zon --allow-delete --data-dir /tmp/nxdns-lab/data
|
||||
```
|
||||
|
||||
```
|
||||
imported /tmp/nxdns-lab/old-backup.zon
|
||||
```
|
||||
|
||||
A restore that only puts back what is already there needs no flag at all, and
|
||||
neither does one that only adds rows. The flag is about deletion specifically —
|
||||
including deletion in disguise: renaming a group, or correcting a typo in an
|
||||
upstream URL, changes the row's identity, so the engine sees one row gone and
|
||||
one arrived.
|
||||
|
||||
What survives a restore is worth knowing before you take an old backup out of
|
||||
the drawer. Blocklist download state is kept for every source whose URL the file
|
||||
still names — checksum, counters, compiled files, and the row id they are keyed
|
||||
by — so restoring does not cost a re-download. Devices the server discovered
|
||||
from traffic are kept whole, and one the backup names keeps the first-seen and
|
||||
last-seen the database already held, so a restore never restamps your network as
|
||||
newly arrived. What does go is a client the backup does not name and that was
|
||||
named by hand: that row is declarative, and it is deleted with the rest.
|
||||
|
||||
> The three `systemctl` lines are the only commands on this page that were not
|
||||
> run: this machine has no installed nxdns unit (`systemctl status nxdns`
|
||||
> answers `Unit nxdns.service could not be found.`) and `systemctl stop`/`start`
|
||||
> need root. The `nxdns import` between them is the same command the lab blocks
|
||||
> above run, which were executed here as written.
|
||||
|
||||
## Restoring the database file itself
|
||||
|
||||
Copying `config.db` back into place works too, and it is the fastest restore on
|
||||
a machine that still has one. Two rules, and the second one is where restores go
|
||||
wrong:
|
||||
|
||||
1. **Take the copy from a stopped instance**, or use `nxdns export` instead. A
|
||||
copy taken while nxdns is running catches the main file without the changes
|
||||
sitting in its write-ahead log.
|
||||
2. **Delete any stale `config.db-wal` and `config.db-shm` beside the file you
|
||||
restore.** SQLite silently discards a write-ahead log that does not match the
|
||||
database it sits next to. It does not warn, and it does not fail — it just
|
||||
answers from the main file, so a restore quietly loses its own tail.
|
||||
|
||||
```sh
|
||||
systemctl stop nxdns
|
||||
rm -f /var/lib/nxdns/config.db-wal /var/lib/nxdns/config.db-shm
|
||||
cp /var/backups/config.db /var/lib/nxdns/config.db
|
||||
chown nxdns:nxdns /var/lib/nxdns/config.db
|
||||
chmod 0600 /var/lib/nxdns/config.db
|
||||
systemctl start nxdns
|
||||
```
|
||||
|
||||
> Not verified on this host: these need root, an installed unit and
|
||||
> `/var/lib/nxdns`, none of which exist here.
|
||||
|
||||
None of this applies in file mode. There `config.db` is derived state — restore
|
||||
the configuration file and start the service, and the first reconcile rebuilds
|
||||
the database from it.
|
||||
|
||||
## Verify a backup
|
||||
|
||||
The round trip is byte-stable: exporting, importing and exporting again gives
|
||||
@@ -200,5 +247,6 @@ startup and before `export` and `import`; nothing walks them back, and `nxdns
|
||||
check` does not run them at all. Take an export before installing a new binary —
|
||||
see [upgrade](upgrade.md).
|
||||
|
||||
Every command on this page was executed on this host as written, except the
|
||||
`systemctl` block marked **Not verified on this host** above.
|
||||
Every `nxdns` command on this page was executed on this host as written. The
|
||||
`systemctl`, `cp`, `chown` and `chmod` lines were not: they need root and an
|
||||
installed unit, and both blocks holding them say so.
|
||||
|
||||
@@ -62,10 +62,12 @@ to:
|
||||
}
|
||||
```
|
||||
|
||||
Write that to `/tmp/nxdns-lab/etc/config.zon`. The configuration file seeds an
|
||||
empty database and is then ignored; to change these settings on a server that
|
||||
already has a database, edit them through the API or through
|
||||
`export`/`import` — see [the configuration model](../explanation/configuration-model.md).
|
||||
Write that to `/tmp/nxdns-lab/etc/config.zon`. The lab runs
|
||||
`nxdns run --config`, which makes that file the configuration: every start
|
||||
reconciles the database onto it, so editing the file and restarting is how these
|
||||
settings change here. A real install may instead run bare `nxdns run` and keep
|
||||
the configuration in the database — see
|
||||
[the configuration model](../explanation/configuration-model.md).
|
||||
|
||||
## 3. Check the files before starting
|
||||
|
||||
|
||||
@@ -10,16 +10,29 @@ supported and is the last section of this page.
|
||||
For what each configuration field means, see
|
||||
[the configuration reference](../reference/configuration.md).
|
||||
|
||||
> Verification: the seed-file failure modes, the run and the two checks in
|
||||
> step 3 were run on the machine that wrote this page, against an image built
|
||||
> from this checkout rather than pulled from the registry — no release is
|
||||
> Verification: the failure modes, the run and the two checks in step 3 were run
|
||||
> on the machine that wrote an earlier revision of this page, against an image
|
||||
> built from this checkout rather than pulled from the registry — no release is
|
||||
> published yet, so nothing on this page could be run against a pulled image,
|
||||
> and step 1 could not be run at all. One command was run in altered form:
|
||||
> host port 8080 was occupied here, so the run and the verification commands
|
||||
> in step 3 were executed with the host side of the port mappings moved to
|
||||
> 25353 and 28088 rather than the 53 and 8080 printed below. The container
|
||||
> side was unchanged. See the note in step 3. The `chown` to uid 65532 needs
|
||||
> root and was not run.
|
||||
> and step 1 could not be run at all. One command was run in altered form: host
|
||||
> port 8080 was occupied there, so the run and the verification commands in
|
||||
> step 3 were executed with the host side of the port mappings moved to 25353
|
||||
> and 28088 rather than the 53 and 8080 printed below. The container side was
|
||||
> unchanged. See the note in step 3. The `chown` to uid 65532 needs root and was
|
||||
> not run.
|
||||
>
|
||||
> **Not re-run for the file-mode revision.** The compose file now ships
|
||||
> `command: ["run", "--config=/etc/nxdns/config.zon"]`, and no container was
|
||||
> started against that command on this host: staging a release image needs
|
||||
> `zig build dist`, which refuses to run while `web/dist` is stale, and the web
|
||||
> bundle was being rebuilt by other work in the same tree at the time. What was
|
||||
> checked instead is `docker compose -f deploy/docker/compose.yaml config`,
|
||||
> which resolves the file without contacting a registry and prints the `command`
|
||||
> and the `:ro` bind mount as written, and the same
|
||||
> `run --config=<file>` invocation driven directly against the locally built
|
||||
> binary: it printed the reconcile summary, `authority: file (<path>)`, and
|
||||
> `no changes` on the second start. The log lines quoted in step 3 come from
|
||||
> that run, with the paths and ports the container uses.
|
||||
|
||||
## 1. Pull and verify the image
|
||||
|
||||
@@ -65,7 +78,7 @@ does and does not prove.
|
||||
> `docker buildx imagetools inspect --format` shape was run here against
|
||||
> `alpine:3.22` on Docker Hub and printed that image's index digest.
|
||||
|
||||
## 2. Get the compose file and write the seed configuration
|
||||
## 2. Get the compose file and write the configuration
|
||||
|
||||
Every path on this page is relative to a checkout of the repository, because
|
||||
that is how it was verified. Running the published image needs no checkout,
|
||||
@@ -82,7 +95,7 @@ curl -fLO "$BASE/raw/tag/v$VERSION/deploy/docker/compose.yaml"
|
||||
> Gitea `1.27.0+dev` and returned the file with a 200.
|
||||
|
||||
Compose bind-mounts `deploy/docker/etc-nxdns` read-only at `/etc/nxdns`. Create
|
||||
it and put the seed file in it:
|
||||
it and put the configuration in it:
|
||||
|
||||
```sh
|
||||
mkdir -p deploy/docker/etc-nxdns
|
||||
@@ -100,15 +113,25 @@ upstream:
|
||||
}
|
||||
```
|
||||
|
||||
Without that file the container exits with code 2 on a fresh volume: an empty
|
||||
database has nothing to forward to. The log is
|
||||
`no configuration file at '/etc/nxdns/config.zon'; using the database as it is`
|
||||
followed by `nxdns run failed: NoUsableUpstreams`.
|
||||
**The compose file ships file mode**, with
|
||||
`command: ["run", "--config=/etc/nxdns/config.zon"]`. That file is the
|
||||
configuration: the container reconciles its database onto it at every start, and
|
||||
the admin interface answers 403 to configuration edits. To change anything, edit
|
||||
the file and restart the container. It also means a fresh or recreated
|
||||
`nxdns-data` volume rebuilds itself from the mounted file with no extra step.
|
||||
|
||||
The file is therefore required, and its absence is a hard failure rather than a
|
||||
start with defaults:
|
||||
|
||||
```
|
||||
FAIL /etc/nxdns/config.zon: no such file
|
||||
nxdns run failed: ManagedConfigUnreadable
|
||||
run `nxdns check` to see the configuration in full
|
||||
```
|
||||
|
||||
A file that is present but rejected is a different failure with the same exit
|
||||
code. No `default` group, no enabled upstream, a syntax error — `run` prints the
|
||||
diagnostic and exits 2 as well. Both were run here against a locally built
|
||||
image. A seed file whose only group was named `other`:
|
||||
diagnostic and exits 2 as well. A file whose only group was named `other`:
|
||||
|
||||
```
|
||||
FAIL groups: no group named 'default'; every unknown client is assigned to it
|
||||
@@ -116,18 +139,31 @@ nxdns run failed: MissingDefaultGroup
|
||||
run `nxdns check` to see the configuration in full
|
||||
```
|
||||
|
||||
and an empty `/etc/nxdns`:
|
||||
Under `restart: unless-stopped` any of these is a restart loop — Docker has no
|
||||
start limit and will retry forever. Read the lines above the failure, which name
|
||||
the fault. See [Troubleshoot nxdns](troubleshoot.md).
|
||||
|
||||
```
|
||||
info(config_bootstrap): no configuration file at '/etc/nxdns/config.zon'; using the database as it is
|
||||
nxdns run failed: NoUsableUpstreams
|
||||
run `nxdns check` to see the configuration in full
|
||||
### Database mode in Docker instead
|
||||
|
||||
Drop the `command:` line from `compose.yaml` and the container runs
|
||||
`nxdns run`, with the database as the configuration and the file read by nothing.
|
||||
On a fresh volume that database is empty and the container exits 2 with
|
||||
`NoUsableUpstreams`, so load it once before bringing the service up:
|
||||
|
||||
```sh
|
||||
docker compose -f deploy/docker/compose.yaml run --rm nxdns import /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
Under `restart: unless-stopped` either one is a restart loop, and the exit code
|
||||
alone no longer tells them apart: read the lines above the failure, which either
|
||||
name the diagnostic in the file or say there was no file at all. See
|
||||
[Troubleshoot nxdns](troubleshoot.md).
|
||||
The file is positional; add `--allow-delete` when re-running it against a
|
||||
populated volume and the diff deletes rows. Without this step, `restart:
|
||||
unless-stopped` plus exit 2 is a crash loop with no way out.
|
||||
|
||||
> Not run in a container on this host, for the reason in the verification note
|
||||
> at the top: no image could be staged here. The `nxdns import <file>` and
|
||||
> `nxdns import <file> --allow-delete` commands inside it were run directly
|
||||
> against the locally built binary — the first applied an additive file and
|
||||
> exited 0, the second was required after a plain `import` refused a
|
||||
> row-deleting file with `DestructiveImport` and exited 2.
|
||||
|
||||
The container runs as uid 65532, and the mount is read-only, so the container
|
||||
cannot repair permissions itself. Mode 0644 works and was used here. If the
|
||||
@@ -140,9 +176,12 @@ chmod 0600 deploy/docker/etc-nxdns/config.zon
|
||||
```
|
||||
|
||||
> Not verified on this host: `chown` to a uid you do not own needs root. What
|
||||
> was verified is the failure it prevents — a seed file at 0600 owned by
|
||||
> another uid makes the container log `nxdns run failed: AccessDenied` and
|
||||
> restart in a loop. See [Troubleshoot nxdns](troubleshoot.md).
|
||||
> was verified is the failure it prevents — a configuration file at 0600 owned
|
||||
> by another uid makes the container refuse to start and restart in a loop. In
|
||||
> file mode an unreadable file is a configuration fault:
|
||||
> `FAIL /etc/nxdns/config.zon: not readable` followed by
|
||||
> `nxdns run failed: ManagedConfigUnreadable`, exit 2. See
|
||||
> [Troubleshoot nxdns](troubleshoot.md).
|
||||
|
||||
## 3. Run it
|
||||
|
||||
@@ -169,14 +208,21 @@ bind mount — against the directory holding the file, not against your shell,
|
||||
and it takes the project name `docker` from that directory either way, which is
|
||||
why the container is `docker-nxdns-1`.
|
||||
|
||||
A healthy first start logs the seeding and the bound sockets:
|
||||
A healthy first start logs the reconcile, the authority and the bound sockets:
|
||||
|
||||
```
|
||||
info(config_bootstrap): seeded the database from '/etc/nxdns/config.zon'
|
||||
info(migrations): config.db migrated from schema version 0 to 2
|
||||
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +45 ~0 -0;
|
||||
settings keys changed: dns.bind_ipv4 dns.bind_ipv6 dns.port web.bind web.port …
|
||||
web authentication is now enabled
|
||||
info(nxdns): authority: file (/etc/nxdns/config.zon)
|
||||
info(nxdns): nxdns <version> serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53; 1 upstream(s); blocklist generation 1
|
||||
info(web_server): web interface listening on 0.0.0.0:8080
|
||||
```
|
||||
|
||||
Every later start on an unchanged file reports `reconciled
|
||||
'/etc/nxdns/config.zon': no changes` and writes nothing to the database.
|
||||
|
||||
Confirm it answers and that the admin interface is up:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -154,11 +154,11 @@ in step 5, and step 4 has to write a file into it before then. systemd does not
|
||||
mind finding the directory already there; it adjusts the mode and ownership to
|
||||
what the unit asks for.
|
||||
|
||||
## 4. Write the seed configuration
|
||||
## 4. Write the configuration
|
||||
|
||||
nxdns starts from an empty database only if a configuration file tells it what
|
||||
to forward to. Write `/etc/nxdns/config.zon`. The smallest file that starts is
|
||||
one group named `default` and one enabled upstream:
|
||||
nxdns will not start with nothing to forward to. Write `/etc/nxdns/config.zon`.
|
||||
The smallest file that starts is one group named `default` and one enabled
|
||||
upstream:
|
||||
|
||||
```zon
|
||||
.{
|
||||
@@ -213,36 +213,49 @@ The upstream probe sends a real query, so this needs working DNS on the host at
|
||||
the time you run it. Exit 2 means `check` found something to fix and printed
|
||||
every problem it found, not only the first.
|
||||
|
||||
The file seeds the database once. From the second start onwards it is ignored
|
||||
and the database is the configuration; see
|
||||
[the configuration model](../explanation/configuration-model.md) and
|
||||
[Upgrade nxdns](upgrade.md) for how to change settings after that.
|
||||
Now load it into the database:
|
||||
|
||||
Once the seed has been consumed — after step 6 confirms you can log in — the
|
||||
plaintext in it is dead weight that only carries risk. The seed's
|
||||
`web.password` is hashed into `web.password_hash` at import time and the
|
||||
plaintext is never stored; `nxdns export` writes `.password = ""` back out
|
||||
alongside the hash. Nothing downstream ever reads the plaintext again, so
|
||||
delete the file:
|
||||
```sh
|
||||
nxdns import /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
```
|
||||
info(migrations): config.db migrated from schema version 0 to 2
|
||||
imported /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
The plaintext password is hashed into `web.password_hash` and never stored as
|
||||
plaintext; `nxdns export` writes `.password = null` beside the hash. Nothing
|
||||
downstream reads the plaintext again, so once step 6 confirms you can log in you
|
||||
can delete the file:
|
||||
|
||||
```sh
|
||||
rm /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
Keep it only if you want the seed as a record of the intended starting
|
||||
configuration, and if you keep it, leave it at 0640 root:nxdns. Note that a
|
||||
kept seed is not a backup — `nxdns export` is
|
||||
(see [Back up and restore](back-up-and-restore.md)), and the export carries the
|
||||
password hash rather than the password.
|
||||
A kept file is not a backup — `nxdns export` is (see
|
||||
[Back up and restore](back-up-and-restore.md)), and the export carries the
|
||||
password hash rather than the password. If you keep it, leave it at 0640
|
||||
root:nxdns.
|
||||
|
||||
That is the **database mode** install, which is what the packaged unit runs:
|
||||
`ExecStart=/usr/local/bin/nxdns run`, no `--config`, so nothing reads a file
|
||||
after this step. Change settings afterwards through the admin interface, the
|
||||
API, or an export–edit–import cycle.
|
||||
|
||||
If you would rather keep `/etc/nxdns/config.zon` in git and have every restart
|
||||
converge onto it, do not delete the file — go to
|
||||
[Run in file mode](#run-in-file-mode) instead, and skip the `rm`.
|
||||
|
||||
> Verified on this host, with a scratch `--config` and `--data-dir` in place of
|
||||
> `/etc/nxdns` and `/var/lib/nxdns`: a seed written under umask 022 came out
|
||||
> 0644, `nxdns check --config` on it printed `OK: no problems found` with no
|
||||
> mode warning, and after `nxdns import` of that seed an `nxdns export` wrote
|
||||
> `.password = ""` next to a populated `.password_hash =
|
||||
> "$argon2id$v=19$..."`. The `chown`, `chmod` and `rm` lines above are the
|
||||
> ordinary root-owned-file operations and were not run against a real
|
||||
> `/etc/nxdns`, which this host does not have.
|
||||
> `/etc/nxdns` and `/var/lib/nxdns` — those two paths are the only difference
|
||||
> from the blocks above. A file written under umask 022 came out 0644,
|
||||
> `nxdns check --config` on it printed `OK: no problems found` with no mode
|
||||
> warning, `nxdns import` of it printed the migration line and `imported <path>`
|
||||
> and exited 0, and a following `nxdns export` wrote `.password = null` next to
|
||||
> a populated `.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…"`. The
|
||||
> `chown`, `chmod` and `rm` lines are ordinary root-owned-file operations and
|
||||
> were not run against a real `/etc/nxdns`, which this host does not have.
|
||||
|
||||
## 5. Start it
|
||||
|
||||
@@ -263,9 +276,16 @@ nxdns writes to stderr and systemd captures that into the journal; logging
|
||||
needs no further configuration. Port 53 is privileged, and the unit grants
|
||||
`CAP_NET_BIND_SERVICE` through `AmbientCapabilities`.
|
||||
|
||||
The unit does not restart the service after exit 2 or exit 64
|
||||
(`RestartPreventExitStatus=2 64`). Those are a wrong configuration and a wrong
|
||||
command line, and neither clears on a retry — restarting every two seconds until
|
||||
`StartLimitBurst` gives up would only bury the diagnostics that are already in
|
||||
the journal. `systemctl status nxdns` shows the failed state; fix the cause and
|
||||
start it again.
|
||||
|
||||
If the start fails, read [Troubleshoot nxdns](troubleshoot.md). The two common
|
||||
first-install failures are a port 53 already held by `systemd-resolved` and a
|
||||
seed file that does not parse.
|
||||
configuration file that does not parse.
|
||||
|
||||
## 6. Confirm it answers
|
||||
|
||||
@@ -276,7 +296,7 @@ dig @<server-ip> example.com A +short
|
||||
```
|
||||
|
||||
The admin interface is on port 8080 by default; log in with the password from
|
||||
the seed file. `http://<server-ip>:8080/api/health` reports upstream
|
||||
the configuration file. `http://<server-ip>:8080/api/health` reports upstream
|
||||
availability and disk state without a login.
|
||||
|
||||
> Not verified on this host as written: `<server-ip>` is a placeholder, and a
|
||||
@@ -287,6 +307,140 @@ availability and disk state without a login.
|
||||
> port returned 200. Only the address and the port differ from the lines
|
||||
> above.
|
||||
|
||||
## Run in file mode
|
||||
|
||||
In file mode `/etc/nxdns/config.zon` is the configuration: every start converges
|
||||
the database onto it, and the admin interface refuses configuration edits with a
|
||||
403 naming the file. Use it when you want the file in git and deployed by
|
||||
Ansible. Stay in database mode when you want the UI to be the way things change.
|
||||
|
||||
The packaged unit is flagless on purpose — it is correct as shipped, and a
|
||||
commented-out alternative `ExecStart` in a unit file is documentation
|
||||
masquerading as configuration. File mode is a drop-in.
|
||||
|
||||
### Adopt file mode on a box that is already running
|
||||
|
||||
Run these in order. **Stop first**, and do not skip that: any edit made through
|
||||
the UI between an export and the restart would be silently reverted by the first
|
||||
reconcile, and `nxdns check` against a live database refuses to grade it (below).
|
||||
|
||||
If you are arriving here from an upgrade, the binary must already be the new
|
||||
one before you export. An export written by 0.0.1 carries a `.password = ""`
|
||||
line this binary refuses; see
|
||||
[the order trap](upgrade.md#the-order-trap-export-with-the-new-binary-not-the-old-one).
|
||||
|
||||
```sh
|
||||
systemctl stop nxdns
|
||||
nxdns export --out /etc/nxdns/config.zon
|
||||
nxdns check --config /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
```
|
||||
wrote /etc/nxdns/config.zon
|
||||
checking configuration file /etc/nxdns/config.zon
|
||||
OK upstreams[0] https://cloudflare-dns.com
|
||||
OK: no problems found
|
||||
```
|
||||
|
||||
Then add the drop-in and start:
|
||||
|
||||
```sh
|
||||
mkdir -p /etc/systemd/system/nxdns.service.d
|
||||
cat > /etc/systemd/system/nxdns.service.d/file-mode.conf <<'EOF'
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/local/bin/nxdns run --config=/etc/nxdns/config.zon
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl start nxdns
|
||||
```
|
||||
|
||||
The empty `ExecStart=` is required. Without it systemd appends a second command
|
||||
to the list rather than replacing the first, and the unit tries to run nxdns
|
||||
twice.
|
||||
|
||||
The first start after adoption changes nothing, because the file was rendered
|
||||
from the database it is now governing:
|
||||
|
||||
```
|
||||
reconciled '/etc/nxdns/config.zon': no changes
|
||||
info(nxdns): authority: file (/etc/nxdns/config.zon)
|
||||
info(nxdns): nxdns <version> serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53; 1 upstream(s); blocklist generation 1
|
||||
```
|
||||
|
||||
`authority: file` is the line that confirms the drop-in took. Blocklists,
|
||||
compiled snapshots and client history all survive, and every later start on an
|
||||
unchanged file writes nothing either.
|
||||
|
||||
The file now carries `web.password_hash`, so restrict it the same way step 4
|
||||
does — `chown root:nxdns`, `chmod 0640`. The unit's `ReadOnlyPaths=/etc/nxdns`
|
||||
denies the service write access to that directory, so the process that reads the
|
||||
file cannot modify it.
|
||||
|
||||
### Change the configuration from now on
|
||||
|
||||
Edit the file, validate it, restart:
|
||||
|
||||
```sh
|
||||
$EDITOR /etc/nxdns/config.zon
|
||||
nxdns check --config /etc/nxdns/config.zon
|
||||
systemctl restart nxdns
|
||||
```
|
||||
|
||||
Make `nxdns check --config` the precondition of any Ansible handler that
|
||||
restarts nxdns. A file-mode start reads the file on **every** boot, so a bad
|
||||
push that skips its handler does not fail at deploy time — it detonates at the
|
||||
next power cut. Validating before restarting turns that into a failed deploy at
|
||||
noon.
|
||||
|
||||
The restart prints what it changed:
|
||||
|
||||
```
|
||||
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +0 ~2 -0;
|
||||
settings keys changed: dns.port web.port
|
||||
```
|
||||
|
||||
### Leave file mode
|
||||
|
||||
Remove the drop-in and restart. The database already holds the last reconciled
|
||||
state, so nothing else is needed and the server comes back serving the same
|
||||
configuration:
|
||||
|
||||
```sh
|
||||
rm /etc/systemd/system/nxdns.service.d/file-mode.conf
|
||||
systemctl daemon-reload
|
||||
systemctl restart nxdns
|
||||
```
|
||||
|
||||
```
|
||||
info(nxdns): authority: database
|
||||
```
|
||||
|
||||
> Verified on this host end to end, against a scratch `--data-dir` and a scratch
|
||||
> configuration path instead of `/var/lib/nxdns` and `/etc/nxdns`, on
|
||||
> unprivileged ports — this machine has neither of those directories, no root,
|
||||
> and no installed unit. Every `nxdns` line above was run and produced the output
|
||||
> shown, with only those paths and the port numbers in the `serving on` line
|
||||
> differing.
|
||||
>
|
||||
> The run: a database-mode instance was started, a blocklist source was added
|
||||
> through the API to make it UI-configured, then `nxdns check` against the live
|
||||
> database printed the uncheckpointed-log FAIL and exited 2 (which is why this
|
||||
> section stops the service first). After the stop, `nxdns export --out` wrote
|
||||
> the file, `nxdns check --config` on it exited 0 with `OK: no problems found`,
|
||||
> and the first file-mode start printed `reconciled '<path>': no changes` and
|
||||
> `authority: file (<path>)`. A second file-mode start printed `no changes`
|
||||
> again and loaded the 3096006-byte compiled blocklist from disk with no
|
||||
> download. Dropping the flag printed `authority: database` and served the same
|
||||
> configuration.
|
||||
>
|
||||
> The `systemctl`, `mkdir`, `cat > …/file-mode.conf` and `rm` lines need root and
|
||||
> an installed unit and were **not** run. What was checked instead:
|
||||
> `systemd-analyze verify` on `deploy/systemd/nxdns.service` with those exact two
|
||||
> `ExecStart` lines appended, which reported only the usual
|
||||
> `Command /usr/local/bin/nxdns is not executable` for the absent binary and
|
||||
> nothing about the override.
|
||||
|
||||
## Raspberry Pi 5
|
||||
|
||||
The Pi 5 is aarch64. Nothing about the procedure changes except which tarball
|
||||
|
||||
@@ -11,7 +11,7 @@ data directory is `/var/lib/nxdns` and the web port is 8080.
|
||||
|
||||
## 1. Set the password
|
||||
|
||||
Put it in the seed configuration file, under `web`:
|
||||
Put it in the configuration file, under `web`:
|
||||
|
||||
```zon
|
||||
.{
|
||||
@@ -21,17 +21,60 @@ Put it in the seed configuration file, under `web`:
|
||||
}
|
||||
```
|
||||
|
||||
At import time the plaintext is hashed with argon2id into `web.password_hash`
|
||||
and discarded. It becomes no database row and appears in no log line. Setting
|
||||
both `password` and `password_hash` in one file is refused:
|
||||
The plaintext is hashed with argon2id into `web.password_hash` and discarded. It
|
||||
becomes no database row and appears in no log line. Setting both `password` and
|
||||
`password_hash` in one file is refused:
|
||||
|
||||
```
|
||||
web.password: password and password_hash are both set; ambiguity in a security setting is refused
|
||||
import failed: PasswordAndHashBothSet
|
||||
```
|
||||
|
||||
The seed file is read only while the database is empty. On a server that
|
||||
already has a database, use step 4 or step 5 instead.
|
||||
Applying that file — with `nxdns import`, or with a `nxdns run --config` start —
|
||||
announces the change:
|
||||
|
||||
```
|
||||
web authentication is now enabled
|
||||
```
|
||||
|
||||
### Absent, empty, and set are three different things
|
||||
|
||||
The two fields are optional, and the difference between leaving one out and
|
||||
setting it to `""` is the difference between keeping your password and removing
|
||||
it:
|
||||
|
||||
| The file says | Effect on the stored password |
|
||||
| --- | --- |
|
||||
| Neither field | Nothing. It stays exactly as it was. |
|
||||
| `.password = "…"` | Installs that password. Unchanged plaintext keeps the existing hash rather than re-hashing it. |
|
||||
| `.password = ""` | Refused. |
|
||||
| `.password_hash = "$argon2id$…"` | Installs that hash, for example from an export. |
|
||||
| `.password_hash = ""` | **Removes the password.** Authentication is off. |
|
||||
|
||||
Absence has to mean "keep", because the alternative is a foot-gun with a live
|
||||
round in it. An export carries the full PHC string, which is long and ugly, and
|
||||
sooner or later someone trims that line out of a file before committing it —
|
||||
meaning "leave the password alone". If absence meant "no password", that edit
|
||||
would open the admin interface to the whole LAN without a word.
|
||||
|
||||
So removing the password takes the explicit empty string:
|
||||
|
||||
```
|
||||
web authentication is now disabled
|
||||
```
|
||||
|
||||
And an empty plaintext is refused outright, because hashing the empty string
|
||||
would switch authentication *on* while making every login impossible — the login
|
||||
handler rejects empty passwords:
|
||||
|
||||
```
|
||||
FAIL web.password: password is set to the empty string; omit the field to keep the stored password, or set password_hash = "" to disable authentication
|
||||
```
|
||||
|
||||
Which of steps 4 and 5 applies to your server depends on its authority. Under
|
||||
`nxdns run --config FILE` the file is the password: edit it and restart, and the
|
||||
API refuses the change with a 403. Under bare `nxdns run` the database holds it,
|
||||
and step 4 or step 5 is how it moves.
|
||||
|
||||
## 2. Log in
|
||||
|
||||
@@ -63,7 +106,7 @@ The session token comes back in a `Set-Cookie` header, not in the body. In the
|
||||
jar it looks like this (value redacted here):
|
||||
|
||||
```
|
||||
#HttpOnly_127.0.0.1 FALSE / FALSE 1785770178 nxdns_session <redacted>
|
||||
#HttpOnly_127.0.0.1 FALSE / FALSE 1786559938 nxdns_session <redacted>
|
||||
```
|
||||
|
||||
The cookie is named `nxdns_session` and carries `HttpOnly; SameSite=Lax;
|
||||
@@ -123,6 +166,9 @@ already is.
|
||||
|
||||
## 4. Change the password on a running server
|
||||
|
||||
This is a database-mode procedure. In file mode `PUT /api/settings` answers 403
|
||||
naming the file; edit `web.password` there and restart instead.
|
||||
|
||||
Send the new one to `PUT /api/settings` as `web.password`. The response is the
|
||||
full settings document; `password` is write-only and `password_hash` is neither
|
||||
readable nor directly writable, so neither value comes back.
|
||||
@@ -161,7 +207,7 @@ Log back in with the new password. That is the whole rotation.
|
||||
|
||||
If you have lost the password, the admin interface cannot help — go through the
|
||||
database instead. Export, edit, import. `nxdns export` always writes
|
||||
`.password = ""` and carries the hash, so an exported file re-imports without
|
||||
`.password = null` and carries the hash, so an exported file re-imports without
|
||||
anyone knowing the password. To install a new one, put it in `.password` and
|
||||
clear `.password_hash`:
|
||||
|
||||
@@ -169,11 +215,22 @@ clear `.password_hash`:
|
||||
nxdns export --data-dir /tmp/nxdns-lab/data --out /tmp/nxdns-lab/rekeyed.zon
|
||||
```
|
||||
|
||||
Edit the `web` section of `/tmp/nxdns-lab/rekeyed.zon` so it reads:
|
||||
Edit the `web` section of `/tmp/nxdns-lab/rekeyed.zon`: set `.password` to the
|
||||
new value and **delete the `.password_hash` line entirely**, so the `web` block
|
||||
carries one password field and not two:
|
||||
|
||||
```zon
|
||||
.password = "offline-password",
|
||||
.password_hash = "",
|
||||
```
|
||||
|
||||
Deleting the line is the part to get right. Setting `.password_hash = ""`
|
||||
alongside a plaintext password does not clear the way for it — an empty string
|
||||
is a present value meaning "no password", so the file then states two
|
||||
contradictory things and is refused:
|
||||
|
||||
```
|
||||
FAIL web.password: password and password_hash are both set; ambiguity in a security setting is refused
|
||||
import failed: PasswordAndHashBothSet
|
||||
```
|
||||
|
||||
Stop the server before importing. `import` rewrites the stored hash underneath a
|
||||
@@ -184,14 +241,17 @@ terminal stops it, and it goes back up with the same command:
|
||||
|
||||
```sh
|
||||
# Ctrl-C the `nxdns run` terminal, or `kill` its pid from another shell
|
||||
nxdns import /tmp/nxdns-lab/rekeyed.zon --force --data-dir /tmp/nxdns-lab/data
|
||||
nxdns run --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon
|
||||
nxdns import /tmp/nxdns-lab/rekeyed.zon --data-dir /tmp/nxdns-lab/data
|
||||
nxdns run --data-dir /tmp/nxdns-lab/data
|
||||
```
|
||||
|
||||
```
|
||||
imported /tmp/nxdns-lab/rekeyed.zon
|
||||
```
|
||||
|
||||
No flag is needed: replacing a password edits a settings value and deletes no
|
||||
rows.
|
||||
|
||||
On a real install the stop and start are `systemctl stop nxdns` and
|
||||
`systemctl start nxdns` around the same `import` — **not verified on this
|
||||
host**, which has no installed nxdns systemd unit (`systemctl status nxdns`
|
||||
@@ -202,7 +262,7 @@ Once it is back up the old password is refused and the new one works:
|
||||
|
||||
```sh
|
||||
curl -sS -X POST http://127.0.0.1:8451/api/auth/login \
|
||||
-H 'content-type: application/json' -d '{"password":"a-new-password"}' \
|
||||
-H 'content-type: application/json' -d '{"password":"lab-password"}' \
|
||||
-w ' (old password, http %{http_code})\n'
|
||||
curl -sS -c /tmp/nxdns-lab/c5.txt -X POST http://127.0.0.1:8451/api/auth/login \
|
||||
-H 'content-type: application/json' -d '{"password":"offline-password"}' \
|
||||
@@ -217,19 +277,19 @@ curl -sS -b /tmp/nxdns-lab/c5.txt -o /dev/null -w 'stats: %{http_code}\n' \
|
||||
stats: 200
|
||||
```
|
||||
|
||||
The next export shows the new hash and an empty `password` again:
|
||||
The next export shows the new hash and a null `password` again:
|
||||
|
||||
```sh
|
||||
nxdns export --data-dir /tmp/nxdns-lab/data | grep password
|
||||
```
|
||||
|
||||
```
|
||||
.password = "",
|
||||
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$kvlRj1tdGul3MlfbvzLncLKWirNpJRJ3howFA9/ysgg$7elW7PPQ3WXHwI4YOmOpZ/1KNEQo7ZDLRJhnYOPMjqw",
|
||||
.password = null,
|
||||
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$xqzK66LgiWGvyCmCl6ZRa3GHH0nS5qZnRgVfWmeGadc$1mafhflKFIg3vcHjJaDMAXGQiOjtym2UADZsPW1xkfw",
|
||||
```
|
||||
|
||||
`--force` is required because the database already holds configuration. See
|
||||
[back up and restore](back-up-and-restore.md).
|
||||
See [back up and restore](back-up-and-restore.md) for when `import` does need
|
||||
`--allow-delete`.
|
||||
|
||||
## What happens with no password set
|
||||
|
||||
@@ -279,6 +339,11 @@ interface at the very least, and preferably set a password.
|
||||
[configuration reference](../reference/configuration.md); the routes are in
|
||||
the [API reference](../reference/api.md).
|
||||
|
||||
Every command on this page was executed on this host as written, except the
|
||||
`systemctl` stop and start named in step 5 and marked **not verified on this
|
||||
host** there.
|
||||
Every command on this page was executed on this host as written, against the
|
||||
lab described at the top, except the `systemctl` stop and start named in step 5
|
||||
and marked **not verified on this host** there. That includes the whole of
|
||||
steps 2 to 5, re-run for this revision: the login, logout and rate-limit
|
||||
transcripts reproduced exactly as printed, the both-set refusal in step 5 was
|
||||
reproduced by leaving `.password_hash = ""` in the file, and the rekey then
|
||||
succeeded once that line was deleted. The cookie jar's expiry timestamp is the
|
||||
one that run produced and will differ on yours.
|
||||
|
||||
+105
-30
@@ -37,10 +37,22 @@ checked on its first line.
|
||||
**Fixes by cause.**
|
||||
|
||||
- `NoUsableUpstreams` — the database has no enabled upstream. On a fresh
|
||||
install this means the seed file was missing or in the wrong place; the start
|
||||
log says `no configuration file at '/etc/nxdns/config.zon'; using the
|
||||
database as it is`. Write the seed file and start again against the still
|
||||
empty database, or `nxdns import <file> --force`.
|
||||
install in database mode this is simply an empty database, and the run says
|
||||
what to do about it on the next line:
|
||||
|
||||
```
|
||||
nxdns run failed: NoUsableUpstreams
|
||||
run `nxdns check` to see the configuration in full
|
||||
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
|
||||
```
|
||||
|
||||
Write a configuration file and take either exit: `nxdns import <file>` to load
|
||||
it into the database once, or add `--config <file>` to `ExecStart` to make the
|
||||
file the configuration from then on.
|
||||
- `ManagedConfigUnreadable` — the service runs `run --config FILE` and that file
|
||||
is missing or the process may not read it. The path is in the FAIL line above
|
||||
the failure. File mode never falls back to the database, on purpose: a
|
||||
fallback would turn a bad deploy into a silently stale configuration.
|
||||
- `BadCertificate` — a DoH or DoT listener is enabled and its certificate or
|
||||
key is unreadable, too large, unparseable, or the key does not belong to the
|
||||
certificate. `run` names both paths before it exits:
|
||||
@@ -79,10 +91,10 @@ checked on its first line.
|
||||
- `BadBindAddress` — `dns.bind_ipv4` or `dns.bind_ipv6` is not an address of
|
||||
that family.
|
||||
|
||||
## A seed file you just wrote is rejected
|
||||
## A configuration file you just wrote is rejected
|
||||
|
||||
**Symptom.** A first start against an empty database prints the validation
|
||||
problem and stops with exit 2:
|
||||
**Symptom.** `nxdns run --config`, `nxdns check --config` or `nxdns import`
|
||||
prints the validation problem and stops with exit 2:
|
||||
|
||||
```
|
||||
FAIL groups: no group named 'default'; every unknown client is assigned to it
|
||||
@@ -98,7 +110,7 @@ nxdns run failed: ParseZon
|
||||
run `nxdns check` to see the configuration in full
|
||||
```
|
||||
|
||||
So does a seed file whose upstream list is empty or all disabled:
|
||||
So does a file whose upstream list is empty or all disabled:
|
||||
|
||||
```
|
||||
FAIL upstreams: at least one upstream must be enabled
|
||||
@@ -106,9 +118,9 @@ nxdns run failed: NoUpstreams
|
||||
run `nxdns check` to see the configuration in full
|
||||
```
|
||||
|
||||
`NoUpstreams` from a seed file is not the same fault as `NoUsableUpstreams`
|
||||
above: the first is a file `run` refused, the second is a database `run`
|
||||
accepted and found empty. Both are exit 2.
|
||||
`NoUpstreams` from a file is not the same fault as `NoUsableUpstreams` above:
|
||||
the first is a file `run` refused, the second is a database `run` accepted and
|
||||
found empty. Both are exit 2.
|
||||
|
||||
**Diagnosis.** Run the same file through `check`, which reports the same
|
||||
problems and exits 2:
|
||||
@@ -117,11 +129,21 @@ problems and exits 2:
|
||||
nxdns check --config /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
**Fix.** Correct the file the diagnostics name and start again. The database is
|
||||
still empty after a failed seed, so the next start re-reads the file. The exit
|
||||
code no longer depends on which command read the file: all three of these files
|
||||
were run through `run`, `check` and `import` here, and every one of the nine
|
||||
combinations exited 2 with the same diagnostic.
|
||||
**Fix.** Correct the file the diagnostics name and start again. Nothing was
|
||||
applied — a file-mode reconcile happens in one transaction that rolls back, and
|
||||
a failed `import` leaves the database untouched. The exit code does not depend
|
||||
on which command read the file: all three of these files were run through `run`,
|
||||
`check` and `import` here, and every one of the nine combinations exited 2 with
|
||||
the same diagnostic.
|
||||
|
||||
Under the shipped systemd unit an exit 2 stops the service rather than
|
||||
restarting it (`RestartPreventExitStatus=2 64`), so the journal holds the
|
||||
diagnostics instead of drowning them in a restart loop. `systemctl start nxdns`
|
||||
once the file is fixed.
|
||||
|
||||
Make `nxdns check --config <file>` the precondition in whatever pushes the file.
|
||||
In file mode every boot reads it, so an unvalidated bad push does not fail at
|
||||
deploy time — it fails at the next restart, which may be a power cut at 3am.
|
||||
|
||||
## `nxdns check` fails on a server that is running fine
|
||||
|
||||
@@ -210,10 +232,11 @@ startup cycle, not a fix.
|
||||
## The container restarts in a loop
|
||||
|
||||
**Symptom.** `docker compose ps` shows the container restarting, and the log is
|
||||
one line repeated:
|
||||
the same failure repeated. Docker has no start limit, so this goes on forever.
|
||||
|
||||
```
|
||||
nxdns run failed: AccessDenied
|
||||
FAIL /etc/nxdns/config.zon: not readable
|
||||
nxdns run failed: ManagedConfigUnreadable
|
||||
```
|
||||
|
||||
**Diagnosis.**
|
||||
@@ -223,10 +246,10 @@ docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}} restarts={{.Restar
|
||||
stat -c '%a %u:%g %n' deploy/docker/etc-nxdns/config.zon
|
||||
```
|
||||
|
||||
Exit 1 with `AccessDenied` means the container could not read the seed file.
|
||||
The container runs as uid 65532 and `/etc/nxdns` is mounted read-only, so a
|
||||
file at mode 0600 owned by your own uid is unreadable to it and the container
|
||||
cannot repair it.
|
||||
Exit 2 naming the configuration path means the container could not read the
|
||||
file the shipped `command:` makes its configuration. The container runs as uid
|
||||
65532 and `/etc/nxdns` is mounted read-only, so a file at mode 0600 owned by
|
||||
your own uid is unreadable to it and the container cannot repair it.
|
||||
|
||||
**Fix.** Either make the file world-readable, when it holds no secret:
|
||||
|
||||
@@ -241,14 +264,65 @@ chown 65532:65532 deploy/docker/etc-nxdns/config.zon
|
||||
chmod 0600 deploy/docker/etc-nxdns/config.zon
|
||||
```
|
||||
|
||||
The 0644 path was verified here, including the recovery: after the `chmod` the
|
||||
container started and answered queries. The `chown` needs root and was not run
|
||||
here.
|
||||
The 0644 path was verified against an earlier revision of this page, including
|
||||
the recovery: after the `chmod` the container started and answered queries. The
|
||||
`chown` needs root and was not run here.
|
||||
|
||||
A container that exits 2 instead — `nxdns run failed: NoUsableUpstreams` after
|
||||
`no configuration file at '/etc/nxdns/config.zon'` — has no seed file at all on
|
||||
a fresh volume. Create `deploy/docker/etc-nxdns/config.zon` and bring it up
|
||||
again; see [Install with Docker](install-with-docker.md).
|
||||
`FAIL /etc/nxdns/config.zon: no such file` instead of `not readable` means there
|
||||
is no configuration file at all. Create `deploy/docker/etc-nxdns/config.zon` and
|
||||
bring it up again; see [Install with Docker](install-with-docker.md).
|
||||
|
||||
A container that exits 2 with `NoUsableUpstreams` is in database mode — the
|
||||
`command:` line naming `--config` was removed — on a volume whose database is
|
||||
still empty. Load one and bring it back up:
|
||||
|
||||
```sh
|
||||
docker compose -f deploy/docker/compose.yaml run --rm nxdns import /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
> Not re-run on this host: staging a release image needs `zig build dist`, which
|
||||
> could not run here while the web bundle was mid-rebuild by other work in the
|
||||
> same checkout. The failure text quoted above is what the same binary prints
|
||||
> outside a container, which was reproduced here, with the container's paths.
|
||||
|
||||
## The admin interface refuses an edit with 403
|
||||
|
||||
**Symptom.** Saving anything in the admin interface fails, and the API answers:
|
||||
|
||||
```json
|
||||
{"error":"configuration is managed by /etc/nxdns/config.zon; edit the file and restart"}
|
||||
```
|
||||
|
||||
This is not a fault. The service runs `nxdns run --config`, which makes that
|
||||
file the configuration, and configuration writes through the API are refused so
|
||||
the file and the running server cannot drift apart.
|
||||
|
||||
**Diagnosis.** The start log names the authority:
|
||||
|
||||
```sh
|
||||
journalctl -u nxdns | grep 'authority:'
|
||||
```
|
||||
|
||||
```
|
||||
info(nxdns): authority: file (/etc/nxdns/config.zon)
|
||||
```
|
||||
|
||||
**Fix.** Edit the file, validate it, restart:
|
||||
|
||||
```sh
|
||||
$EDITOR /etc/nxdns/config.zon
|
||||
nxdns check --config /etc/nxdns/config.zon
|
||||
systemctl restart nxdns
|
||||
```
|
||||
|
||||
Or, if you want the interface to be how this box is configured, leave file mode:
|
||||
drop `--config` from `ExecStart` and restart. The database already holds the
|
||||
last reconciled state, so nothing is lost. See
|
||||
[Run in file mode](install-with-systemd.md#run-in-file-mode).
|
||||
|
||||
Pausing blocking, refreshing blocklists and reloading certificates are not
|
||||
configuration and keep working in file mode. Deleting a client works too, unless
|
||||
the file names that client's address.
|
||||
|
||||
## The container cannot reach its upstreams
|
||||
|
||||
@@ -324,7 +398,8 @@ window of unfiltered answers.
|
||||
A generation number with nothing being blocked is a different problem: the
|
||||
snapshot loaded but has no sources in it. The line
|
||||
`blocklist snapshot generation 1: 0 of 0 sources loaded` says exactly that. Add
|
||||
a source in the admin interface, or in the seed file before the first start.
|
||||
a source in the admin interface, or a `blocklist_sources` entry to the
|
||||
configuration file with a `group_sources` link naming a group.
|
||||
|
||||
## A database stamped by a newer binary
|
||||
|
||||
|
||||
+179
-27
@@ -21,6 +21,105 @@ Upgrading a build you made yourself is the last section of this page.
|
||||
> shapes and the verification commands are covered by
|
||||
> [Verify a release](verify-a-release.md), which says what was probed and how.
|
||||
|
||||
## Breaking change: `run --config` now means file authority
|
||||
|
||||
**Read this before upgrading if anything on your box passes `--config` to
|
||||
`nxdns run`** — a systemd drop-in, a wrapper script, or a `command:` in a
|
||||
compose file.
|
||||
|
||||
`run --config FILE` used to mean *seed once*: the file was read only while the
|
||||
database was still empty, and ignored on every start after that. It now means
|
||||
*the file is the configuration*: every start reconciles the database onto it.
|
||||
|
||||
For a box that was seeded once and then configured through the admin interface,
|
||||
the first start after the upgrade converges the database back to that old seed
|
||||
file. **Every change made through the UI since seeding is deleted.**
|
||||
|
||||
There are two ways out, and you pick before you restart:
|
||||
|
||||
- **Keep the database.** Drop the flag. `nxdns run` with no `--config` serves
|
||||
the database exactly as it did before, and nothing reads a file. This is the
|
||||
right answer if the UI is how you change things.
|
||||
- **Adopt file mode cleanly.** Install the new binary, stop the service, export
|
||||
the current database over the file path, check it, then start with the flag.
|
||||
The first reconcile is then a no-op, because the file was rendered from the
|
||||
database it governs. **Install the new binary first** — see the order trap
|
||||
below. The full procedure is
|
||||
[Adopt file mode](install-with-systemd.md#adopt-file-mode-on-a-box-that-is-already-running).
|
||||
|
||||
`nxdns check --config FILE` is unchanged: it graded that file before and it
|
||||
grades that file now.
|
||||
|
||||
### The order trap: export with the new binary, not the old one
|
||||
|
||||
Take the export **after** you have replaced the binary, with the service
|
||||
stopped. Exporting first — the instinctive order, and the one step 1 of this
|
||||
page tells you to take for a backup — produces a file the new binary refuses.
|
||||
|
||||
A 0.0.1 `nxdns export` writes both fields:
|
||||
|
||||
```zon
|
||||
.password = "",
|
||||
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…",
|
||||
```
|
||||
|
||||
An empty `password_hash` used to mean "unset". It now means "disable
|
||||
authentication", so it is a *present* value — and a file that carries both
|
||||
fields states two different things about the password and is refused:
|
||||
|
||||
```
|
||||
FAIL web.password: password and password_hash are both set; ambiguity in a security setting is refused
|
||||
FAIL web.password: password is set to the empty string; omit the field to keep the stored password, or set password_hash = "" to disable authentication
|
||||
nxdns run failed: PasswordAndHashBothSet
|
||||
```
|
||||
|
||||
The old `nxdns check` passes that file, because the old binary agreed with the
|
||||
old rule. So the failure lands at the first start after the upgrade, with the
|
||||
resolver stopped and the unit refusing to retry it
|
||||
(`RestartPreventExitStatus=2 64`). A new `nxdns export` writes
|
||||
`.password = null` instead and has no such problem.
|
||||
|
||||
**If you already have an old export you want to adopt**, you do not need to
|
||||
redo it. Delete the empty-password line and the file is valid:
|
||||
|
||||
```sh
|
||||
sed -i '/^ \.password = "",$/d' /etc/nxdns/config.zon
|
||||
nxdns check --config /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
Keep the `.password_hash` line — that is the password, and deleting it as well
|
||||
would leave the file saying nothing about authentication, which means "keep
|
||||
whatever is stored" rather than anything you would notice.
|
||||
|
||||
The same trap has nothing to do with file mode as such: it is any 0.0.1 export
|
||||
fed to the new binary, so it also applies to a restore through `nxdns import`.
|
||||
Backups taken with 0.0.1 need that one line removed before they will load.
|
||||
|
||||
> Verified on this host, with one substitution stated: the repository has no
|
||||
> 0.0.1 binary to hand, so the old export was **simulated** by taking a current
|
||||
> `nxdns export` and rewriting `.password = null` to `.password = ""`, which is
|
||||
> the one byte-level difference between the two formats. Against that file,
|
||||
> `nxdns check --config` printed both FAIL lines above and exited 2, and
|
||||
> `nxdns run --config` printed them and failed `PasswordAndHashBothSet`. After
|
||||
> the `sed` above, `nxdns check --config` exited 0 with `OK: no problems found`
|
||||
> and `nxdns import` of the same file exited 0, keeping the hash. The claim
|
||||
> about what 0.0.1's `export` emitted is read from that release's source —
|
||||
> `git show v0.0.1:src/config/export.zig` line 71 is `cfg.web.password = "";` —
|
||||
> not from running that binary.
|
||||
|
||||
A database-mode install that never passed `--config` needs nothing. Under
|
||||
Docker, a fresh database-mode install must either take the new compose file or
|
||||
run `import` once — see
|
||||
[Database mode in Docker](install-with-docker.md#database-mode-in-docker-instead).
|
||||
|
||||
Two smaller renames in the same release: `nxdns import --force` is now
|
||||
`--allow-delete`, and it is required only when the file's diff would delete
|
||||
rows rather than whenever the database is non-empty. `nxdns check` no longer
|
||||
falls back to a default file path when there is no database; it reports the
|
||||
absent database and names the two ways to get one.
|
||||
|
||||
There is no schema migration in this change.
|
||||
|
||||
## 1. Take an export first
|
||||
|
||||
There is no downgrade path, so the export is what you fall back to:
|
||||
@@ -33,6 +132,13 @@ nxdns export --out /some/backup/nxdns-config.zon
|
||||
command relies on the default `--data-dir /var/lib/nxdns` that a systemd
|
||||
install has.
|
||||
|
||||
This export is a fallback, not a file to deploy. If you are adopting file mode,
|
||||
take a *second* export after the binary swap and use that one — an export
|
||||
written by 0.0.1 carries a `.password = ""` line the new binary refuses, as
|
||||
[the order trap](#the-order-trap-export-with-the-new-binary-not-the-old-one)
|
||||
explains. The same line has to come out of this backup before the new binary
|
||||
will import it.
|
||||
|
||||
> Verified on this host with both paths substituted, since it has neither
|
||||
> `/var/lib/nxdns` nor `/some/backup`. `SCRATCH` below is a scratch directory,
|
||||
> and its `data/` was populated beforehand with `nxdns import`:
|
||||
@@ -131,9 +237,11 @@ NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml pull
|
||||
NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml up -d
|
||||
```
|
||||
|
||||
Compose recreates the container against the same `nxdns-data` volume. The seed
|
||||
file in `etc-nxdns` is not read again; the database in the volume is the
|
||||
configuration.
|
||||
Compose recreates the container against the same `nxdns-data` volume. What
|
||||
happens to the file in `etc-nxdns` depends on the `command:` in your compose
|
||||
file: with the shipped `run --config=/etc/nxdns/config.zon` the file is the
|
||||
configuration and the restart reconciles onto it; without it, the database in
|
||||
the volume is the configuration and the file is read by nothing.
|
||||
|
||||
Set `NXDNS_VERSION` on both lines, or export it. Without it the compose file
|
||||
falls back to `:latest`, and `pull` and `up` could then land on different
|
||||
@@ -184,11 +292,12 @@ OK: no problems found
|
||||
> zig 0.16.0
|
||||
> ```
|
||||
>
|
||||
> Against a running server whose database had just been migrated and seeded,
|
||||
> `nxdns check --data-dir` printed the uncheckpointed-log line above and exited
|
||||
> 2, while `nxdns export --out` followed by `nxdns check --config` on the result
|
||||
> exited 0 with `OK: no problems found`. The `dig` line was not run in this
|
||||
> round: nothing is listening on 127.0.0.1:53 here, and port 53 needs root.
|
||||
> Against a running server whose database had just taken a configuration write
|
||||
> through the API, `nxdns check --data-dir` printed the uncheckpointed-log line
|
||||
> above and exited 2, while `nxdns export --out` followed by
|
||||
> `nxdns check --config` on the result exited 0 with `OK: no problems found`.
|
||||
> Both were re-run for this revision. The `dig` line was not run in this round:
|
||||
> nothing is listening on 127.0.0.1:53 here, and port 53 needs root.
|
||||
|
||||
## What happens to the database
|
||||
|
||||
@@ -235,46 +344,89 @@ nxdns run failed: SchemaTooNew
|
||||
That run exits 1. Recovering means importing the export you took in step 1 into
|
||||
a fresh data directory with the older binary.
|
||||
|
||||
### Rolling back from file mode
|
||||
|
||||
Putting an older binary back needs no unit edit. The old binary accepts
|
||||
`run --config` — it just reads it as the old seed-once flag — and against a
|
||||
database that already holds configuration it ignores the file entirely and
|
||||
serves the last state the new binary reconciled. So the service comes back up
|
||||
on the configuration it was running.
|
||||
|
||||
The consequence is worth stating plainly: **file edits stop applying.** The old
|
||||
binary will not re-read the file, so every change made to `config.zon` after the
|
||||
rollback does nothing at all, silently, until the newer binary is back. If you
|
||||
have to stay on the old binary, use `nxdns import` to apply file changes, or drop
|
||||
the flag so the invocation matches what the binary actually does.
|
||||
|
||||
The schema note above still governs: a database stamped by a newer binary
|
||||
refuses to open, whatever mode either binary runs in.
|
||||
|
||||
## Changing settings, not the binary
|
||||
|
||||
An upgrade never re-reads `/etc/nxdns/config.zon`. After the first successful
|
||||
seed the file is ignored, and the start log says so:
|
||||
How you change a setting depends on which authority the service runs under.
|
||||
`nxdns run` in `ExecStart` means the database; `nxdns run --config FILE` means
|
||||
the file. The start log names it either way:
|
||||
|
||||
```
|
||||
info(config_bootstrap): configuration file ignored; the database is already configured
|
||||
info(nxdns): authority: database
|
||||
info(nxdns): authority: file (/etc/nxdns/config.zon)
|
||||
```
|
||||
|
||||
Change settings through the admin interface, through the API, or with an
|
||||
export–edit–import cycle against a stopped server:
|
||||
**In file mode**, edit the file, validate it, restart. The admin interface will
|
||||
refuse the change with a 403 naming the file, so there is nothing to get wrong:
|
||||
|
||||
```sh
|
||||
$EDITOR /etc/nxdns/config.zon
|
||||
nxdns check --config /etc/nxdns/config.zon
|
||||
systemctl restart nxdns
|
||||
```
|
||||
|
||||
**In database mode**, change settings through the admin interface, through the
|
||||
API, or with an export–edit–import cycle against a stopped server:
|
||||
|
||||
```sh
|
||||
nxdns export --out config-backup.zon
|
||||
$EDITOR config-backup.zon
|
||||
systemctl stop nxdns
|
||||
nxdns import config-backup.zon --force
|
||||
nxdns import config-backup.zon
|
||||
systemctl start nxdns
|
||||
```
|
||||
|
||||
`--force` is required here. A plain `import` into a database that already holds
|
||||
configuration fails with `import failed: DatabaseNotEmpty` and exits 2, so it
|
||||
cannot clobber a configured server by accident. What counts is what an operator
|
||||
set: client rows the DNS path materialised from traffic never trigger the
|
||||
refusal on their own.
|
||||
`import` needs no flag to add rows or to edit them. It needs `--allow-delete`
|
||||
only when applying the file would delete rows the database holds — including the
|
||||
case where you renamed something, since changing a group's name or an upstream's
|
||||
URL is a delete and an insert to the engine, not an edit. The refusal names the
|
||||
tables and rolls back:
|
||||
|
||||
> Verified on this host for the two `nxdns` lines, against a populated scratch
|
||||
> data directory:
|
||||
```
|
||||
FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it
|
||||
import failed: DestructiveImport
|
||||
```
|
||||
|
||||
Stop the server first either way. `import` rewrites configuration underneath a
|
||||
process that read it at startup, and a running server picks up only some of it.
|
||||
|
||||
> Verified on this host against a populated scratch data directory, with
|
||||
> `--data-dir` pointing at it — that path is the only difference from the blocks
|
||||
> above:
|
||||
>
|
||||
> ```
|
||||
> $ nxdns import $SCRATCH/nxdns-config.zon --data-dir $SCRATCH/data
|
||||
> import failed: DatabaseNotEmpty
|
||||
> $ nxdns import $SCRATCH/etc/config.zon --data-dir $SCRATCH/dbmode
|
||||
> imported /…/config.zon
|
||||
> (exit 0)
|
||||
> $ nxdns import $SCRATCH/etc/smaller.zon --data-dir $SCRATCH/dbmode
|
||||
> FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it
|
||||
> import failed: DestructiveImport
|
||||
> (exit 2)
|
||||
> $ nxdns import $SCRATCH/nxdns-config.zon --data-dir $SCRATCH/data --force
|
||||
> imported /…/scratchpad/nxdns-config.zon
|
||||
> $ nxdns import $SCRATCH/etc/smaller.zon --data-dir $SCRATCH/dbmode --allow-delete
|
||||
> imported /…/smaller.zon
|
||||
> (exit 0)
|
||||
> ```
|
||||
>
|
||||
> The `systemctl stop`/`start` lines around them need root and an installed
|
||||
> service and were not run; `$EDITOR` is yours to run.
|
||||
> The first of those three is the additive case that needs no flag; the second
|
||||
> file replaced the upstream, which is an identity change and therefore a
|
||||
> delete. The `systemctl stop`/`start` lines need root and an installed service
|
||||
> and were not run; `$EDITOR` is yours to run.
|
||||
|
||||
## Upgrading to a build of your own
|
||||
|
||||
|
||||
+137
-59
@@ -31,6 +31,9 @@ below carries all 56 of its entries.
|
||||
- Mutations to groups, blocklists, rules, local records, forward zones, clients
|
||||
and client prefixes take effect live. Upstreams and `/api/settings` are
|
||||
restart-required.
|
||||
- Every route has a policy class — `read`, `config_write` or `runtime_action` —
|
||||
and in file mode the `config_write` routes are refused. See
|
||||
[Configuration authority](#configuration-authority).
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -113,70 +116,140 @@ to the capacity is admitted and the long-run rate holds.
|
||||
holds at most 32 concurrent streams in total; when all slots are taken, the
|
||||
answer is a 503.
|
||||
|
||||
## Configuration authority
|
||||
|
||||
Which authority is live decides whether the API may write configuration. Under
|
||||
`nxdns run` the database is authority and every route behaves as it always has.
|
||||
Under `nxdns run --config FILE` the file is authority, and the routes that would
|
||||
edit configuration are refused: the file is the only place configuration
|
||||
changes, and a restart is what applies them.
|
||||
|
||||
### The refusal
|
||||
|
||||
A `config write` route in file mode answers **403** with the ordinary error
|
||||
envelope:
|
||||
|
||||
```json
|
||||
{"error":"configuration is managed by /etc/nxdns/config.zon; edit the file and restart"}
|
||||
```
|
||||
|
||||
There is no `code` field and no richer body. 403 is used for nothing else in
|
||||
this API, so the status alone is the machine-readable part, and a client that
|
||||
wants to know the mode in advance reads it from `GET /api/settings` rather than
|
||||
probing for errors.
|
||||
|
||||
**401 comes first.** The router matches the path, spends a rate-limit token,
|
||||
checks the session, and only then checks the policy. So an unauthenticated
|
||||
request to a `config write` route in file mode is a 401, not a 403 — answering
|
||||
403 first would tell an anonymous caller which routes exist.
|
||||
|
||||
`runtime action` and `read` routes are unaffected in both modes. Pausing
|
||||
blocking, refreshing blocklists, reloading certificates and logging in are
|
||||
operations on a running process, not statements about configuration, so a
|
||||
file-mode box still does all of them.
|
||||
|
||||
`DELETE /api/clients/{id}` is the one route whose answer depends on the row.
|
||||
Deleting a client the file does not declare is a runtime action and succeeds:
|
||||
without it, a mis-identified or departed device would be immortal in file mode,
|
||||
since the file can add addresses but never remove one it has never named.
|
||||
Deleting a client the file *does* declare contradicts the file, and answers the
|
||||
same 403.
|
||||
|
||||
### Discovering the authority
|
||||
|
||||
`GET /api/settings` carries an `authority` object:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `mode` | `"database"` or `"managed_file"`. |
|
||||
| `path` | The managed file's path, or `null` in database mode. |
|
||||
| `reconciled_at` | Unix seconds when this process loaded the file, or `null` in database mode. |
|
||||
|
||||
All three keys are always present; the two nullable ones carry `null` rather
|
||||
than being omitted, so a client can read `authority.mode` without probing.
|
||||
|
||||
```json
|
||||
{"mode": "database", "path": null, "reconciled_at": null}
|
||||
{"mode": "managed_file", "path": "/etc/nxdns/config.zon", "reconciled_at": 1786474016}
|
||||
```
|
||||
|
||||
The route requires a session, which is why the filesystem path is here rather
|
||||
than on the open `/api/version` and `/api/health`.
|
||||
|
||||
`reconciled_at` answers exactly one question: **when did this process last read
|
||||
the file?** Compare it against the file's mtime to spot a restart that has not
|
||||
happened yet. It is a hint and not a verdict, in both directions — a clock that
|
||||
stepped, or a copy that preserved mtimes (`git checkout`, `rsync -a`), can make
|
||||
a newer file look older, and the database can change without either timestamp
|
||||
moving. It does not tell you whether the file and the running configuration
|
||||
agree; answering that would take content hashing, which nxdns deliberately does
|
||||
not do.
|
||||
|
||||
## Operations
|
||||
|
||||
Auth `open` means no session is required; `session` means a valid session cookie
|
||||
is required whenever a password is set. Rate limit `counted` spends a token;
|
||||
`exempt` never consults the limiter.
|
||||
`exempt` never consults the limiter. Policy `config write` is the class refused
|
||||
in file mode; `read` and `runtime action` are always served.
|
||||
|
||||
| Method | Path | Auth | Rate limit | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/metrics` | open | exempt | Prometheus metrics |
|
||||
| GET | `/api/health` | open | exempt | Health rollup |
|
||||
| GET | `/api/version` | open | counted | Build and uptime |
|
||||
| GET | `/api/openapi.yaml` | open | counted | This API's OpenAPI document |
|
||||
| POST | `/api/auth/login` | open | counted | Log in |
|
||||
| POST | `/api/auth/logout` | session | counted | Log out |
|
||||
| GET | `/api/queries` | session | counted | Query log page |
|
||||
| GET | `/api/queries/live` | session | exempt | Live query stream (server-sent events) |
|
||||
| GET | `/api/stats` | session | counted | Totals for a period |
|
||||
| GET | `/api/stats/timeseries` | session | counted | Bucketed counts for a period |
|
||||
| GET | `/api/lookup` | session | counted | Explain a domain |
|
||||
| GET | `/api/upstream/health` | session | counted | Upstream pool health |
|
||||
| GET | `/api/groups` | session | counted | List groups |
|
||||
| POST | `/api/groups` | session | counted | Create a group |
|
||||
| GET | `/api/groups/{id}` | session | counted | Read a group |
|
||||
| PUT | `/api/groups/{id}` | session | counted | Update a group |
|
||||
| DELETE | `/api/groups/{id}` | session | counted | Delete a group |
|
||||
| GET | `/api/groups/{id}/sources` | session | counted | Blocklist sources assigned to a group |
|
||||
| PUT | `/api/groups/{id}/sources` | session | counted | Replace the assignment |
|
||||
| GET | `/api/blocklists` | session | counted | List blocklist sources |
|
||||
| POST | `/api/blocklists` | session | counted | Add a blocklist source |
|
||||
| POST | `/api/blocklists/update` | session | counted | Refresh every enabled source now |
|
||||
| GET | `/api/blocklists/{id}` | session | counted | Read a blocklist source |
|
||||
| PUT | `/api/blocklists/{id}` | session | counted | Update a blocklist source |
|
||||
| DELETE | `/api/blocklists/{id}` | session | counted | Delete a blocklist source |
|
||||
| GET | `/api/rules` | session | counted | List rules |
|
||||
| POST | `/api/rules` | session | counted | Create a rule |
|
||||
| GET | `/api/rules/{id}` | session | counted | Read a rule |
|
||||
| PUT | `/api/rules/{id}` | session | counted | Update a rule |
|
||||
| DELETE | `/api/rules/{id}` | session | counted | Delete a rule |
|
||||
| GET | `/api/local-records` | session | counted | List local DNS records |
|
||||
| POST | `/api/local-records` | session | counted | Create a local record |
|
||||
| GET | `/api/local-records/{id}` | session | counted | Read a local record |
|
||||
| PUT | `/api/local-records/{id}` | session | counted | Update a local record |
|
||||
| DELETE | `/api/local-records/{id}` | session | counted | Delete a local record |
|
||||
| GET | `/api/forward-zones` | session | counted | List forward zones |
|
||||
| POST | `/api/forward-zones` | session | counted | Create a forward zone |
|
||||
| GET | `/api/forward-zones/{id}` | session | counted | Read a forward zone |
|
||||
| PUT | `/api/forward-zones/{id}` | session | counted | Update a forward zone |
|
||||
| DELETE | `/api/forward-zones/{id}` | session | counted | Delete a forward zone |
|
||||
| GET | `/api/clients` | session | counted | List clients |
|
||||
| GET | `/api/clients/{id}` | session | counted | Read a client |
|
||||
| PUT | `/api/clients/{id}` | session | counted | Rename or regroup a client |
|
||||
| DELETE | `/api/clients/{id}` | session | counted | Forget a client |
|
||||
| GET | `/api/client-prefixes` | session | counted | List client prefixes |
|
||||
| PUT | `/api/client-prefixes` | session | counted | Replace the prefix table |
|
||||
| GET | `/api/upstreams` | session | counted | List upstream resolvers |
|
||||
| POST | `/api/upstreams` | session | counted | Add an upstream |
|
||||
| GET | `/api/upstreams/{id}` | session | counted | Read an upstream |
|
||||
| PUT | `/api/upstreams/{id}` | session | counted | Update an upstream |
|
||||
| DELETE | `/api/upstreams/{id}` | session | counted | Delete an upstream |
|
||||
| GET | `/api/pause` | session | counted | Read the pause state |
|
||||
| POST | `/api/pause` | session | counted | Pause or resume blocking |
|
||||
| GET | `/api/settings` | session | counted | Read the scalar settings |
|
||||
| PUT | `/api/settings` | session | counted | Update settings |
|
||||
| POST | `/api/certs/reload` | session | counted | Reload the TLS certificates from disk |
|
||||
| Method | Path | Auth | Rate limit | Policy | Purpose |
|
||||
|---|---|---|---|---|---|
|
||||
| GET | `/metrics` | open | exempt | read | Prometheus metrics |
|
||||
| GET | `/api/health` | open | exempt | read | Health rollup |
|
||||
| GET | `/api/version` | open | counted | read | Build and uptime |
|
||||
| GET | `/api/openapi.yaml` | open | counted | read | This API's OpenAPI document |
|
||||
| POST | `/api/auth/login` | open | counted | runtime action | Log in |
|
||||
| POST | `/api/auth/logout` | session | counted | runtime action | Log out |
|
||||
| GET | `/api/queries` | session | counted | read | Query log page |
|
||||
| GET | `/api/queries/live` | session | exempt | read | Live query stream (server-sent events) |
|
||||
| GET | `/api/stats` | session | counted | read | Totals for a period |
|
||||
| GET | `/api/stats/timeseries` | session | counted | read | Bucketed counts for a period |
|
||||
| GET | `/api/lookup` | session | counted | read | Explain a domain |
|
||||
| GET | `/api/upstream/health` | session | counted | read | Upstream pool health |
|
||||
| GET | `/api/groups` | session | counted | read | List groups |
|
||||
| POST | `/api/groups` | session | counted | config write | Create a group |
|
||||
| GET | `/api/groups/{id}` | session | counted | read | Read a group |
|
||||
| PUT | `/api/groups/{id}` | session | counted | config write | Update a group |
|
||||
| DELETE | `/api/groups/{id}` | session | counted | config write | Delete a group |
|
||||
| GET | `/api/groups/{id}/sources` | session | counted | read | Blocklist sources assigned to a group |
|
||||
| PUT | `/api/groups/{id}/sources` | session | counted | config write | Replace the assignment |
|
||||
| GET | `/api/blocklists` | session | counted | read | List blocklist sources |
|
||||
| POST | `/api/blocklists` | session | counted | config write | Add a blocklist source |
|
||||
| POST | `/api/blocklists/update` | session | counted | runtime action | Refresh every enabled source now |
|
||||
| GET | `/api/blocklists/{id}` | session | counted | read | Read a blocklist source |
|
||||
| PUT | `/api/blocklists/{id}` | session | counted | config write | Update a blocklist source |
|
||||
| DELETE | `/api/blocklists/{id}` | session | counted | config write | Delete a blocklist source |
|
||||
| GET | `/api/rules` | session | counted | read | List rules |
|
||||
| POST | `/api/rules` | session | counted | config write | Create a rule |
|
||||
| GET | `/api/rules/{id}` | session | counted | read | Read a rule |
|
||||
| PUT | `/api/rules/{id}` | session | counted | config write | Update a rule |
|
||||
| DELETE | `/api/rules/{id}` | session | counted | config write | Delete a rule |
|
||||
| GET | `/api/local-records` | session | counted | read | List local DNS records |
|
||||
| POST | `/api/local-records` | session | counted | config write | Create a local record |
|
||||
| GET | `/api/local-records/{id}` | session | counted | read | Read a local record |
|
||||
| PUT | `/api/local-records/{id}` | session | counted | config write | Update a local record |
|
||||
| DELETE | `/api/local-records/{id}` | session | counted | config write | Delete a local record |
|
||||
| GET | `/api/forward-zones` | session | counted | read | List forward zones |
|
||||
| POST | `/api/forward-zones` | session | counted | config write | Create a forward zone |
|
||||
| GET | `/api/forward-zones/{id}` | session | counted | read | Read a forward zone |
|
||||
| PUT | `/api/forward-zones/{id}` | session | counted | config write | Update a forward zone |
|
||||
| DELETE | `/api/forward-zones/{id}` | session | counted | config write | Delete a forward zone |
|
||||
| GET | `/api/clients` | session | counted | read | List clients |
|
||||
| GET | `/api/clients/{id}` | session | counted | read | Read a client |
|
||||
| PUT | `/api/clients/{id}` | session | counted | config write | Rename or regroup a client |
|
||||
| DELETE | `/api/clients/{id}` | session | counted | runtime action | Forget a client |
|
||||
| GET | `/api/client-prefixes` | session | counted | read | List client prefixes |
|
||||
| PUT | `/api/client-prefixes` | session | counted | config write | Replace the prefix table |
|
||||
| GET | `/api/upstreams` | session | counted | read | List upstream resolvers |
|
||||
| POST | `/api/upstreams` | session | counted | config write | Add an upstream |
|
||||
| GET | `/api/upstreams/{id}` | session | counted | read | Read an upstream |
|
||||
| PUT | `/api/upstreams/{id}` | session | counted | config write | Update an upstream |
|
||||
| DELETE | `/api/upstreams/{id}` | session | counted | config write | Delete an upstream |
|
||||
| GET | `/api/pause` | session | counted | read | Read the pause state |
|
||||
| POST | `/api/pause` | session | counted | runtime action | Pause or resume blocking |
|
||||
| GET | `/api/settings` | session | counted | read | Read the scalar settings |
|
||||
| PUT | `/api/settings` | session | counted | config write | Update settings |
|
||||
| POST | `/api/certs/reload` | session | counted | runtime action | Reload the TLS certificates from disk |
|
||||
|
||||
There is no `POST /api/clients`: client rows come from DNS activity or import,
|
||||
never from the API.
|
||||
@@ -194,6 +267,11 @@ write-only (accepted on a `PUT`, never returned, hashed before storage), and
|
||||
`web.password_hash` is neither readable nor directly writable, because a client
|
||||
that could install a hash could install one whose password it already knows.
|
||||
|
||||
In file mode `PUT /api/settings` is refused with the 403 above, password changes
|
||||
included. The password then lives where the rest of the configuration lives: set
|
||||
`web.password` in the file and restart. See
|
||||
[Password and hash](configuration.md#password-and-hash).
|
||||
|
||||
## Schemas
|
||||
|
||||
Request and response schemas for every operation live in the OpenAPI document:
|
||||
|
||||
+212
-48
@@ -9,9 +9,10 @@ of truth: `src/cli.zig`.
|
||||
|
||||
Every flag takes both spellings, `--flag value` and `--flag=value`. An attached
|
||||
value that is empty (`--config=`) is a missing value, not an empty path.
|
||||
`--force` is boolean and takes no value at all, so `--force=1` is not a spelling
|
||||
of any flag this program has. A flag is rejected by the subcommand that has no
|
||||
use for it: `--web-dev` outside `run` is an unknown flag, not a no-op.
|
||||
`--allow-delete` is boolean and takes no value at all, so `--allow-delete=1` is
|
||||
not a spelling of any flag this program has. A flag is rejected by the
|
||||
subcommand that has no use for it: `--web-dev` outside `run` is an unknown flag,
|
||||
not a no-op.
|
||||
|
||||
## `run`
|
||||
|
||||
@@ -20,12 +21,81 @@ Serves DNS until SIGINT or SIGTERM.
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir DIR` | Data directory (default `/var/lib/nxdns`). Created at mode 0700 if missing. |
|
||||
| `--config FILE` | Seed configuration file (default `/etc/nxdns/config.zon`). Read only when the database has never been configured. |
|
||||
| `--config FILE` | Make FILE the sole source of configuration and reconcile the database onto it at every start. No default: without this flag the database is the configuration and no file is read. |
|
||||
| `--web-dev DIR` | Serve the web interface from DIR instead of the embedded assets, with no cache headers. Development only. |
|
||||
|
||||
A seed file that is unparseable, oversized or invalid prints its diagnostics and
|
||||
exits 2 — the same code `check` and `import` give for the same file. See
|
||||
[exit codes](#exit-codes).
|
||||
### Which authority the invocation selects
|
||||
|
||||
The presence of `--config` picks the authority, and nothing else does. There is
|
||||
no default path, no probe of `/etc/nxdns`, and nothing recorded in the database:
|
||||
a configuration file sitting at `/etc/nxdns/config.zon` that no flag names
|
||||
changes nothing at all.
|
||||
|
||||
| Invocation | Authority | What a start does |
|
||||
| --- | --- | --- |
|
||||
| `nxdns run` | The database | Serves what `config.db` holds. Nothing reads a file. |
|
||||
| `nxdns run --config FILE` | FILE | Reads and validates FILE, reconciles the database onto it, then serves. |
|
||||
|
||||
The first log line after the migrations names the mode, so a journal says which
|
||||
authority was live:
|
||||
|
||||
```
|
||||
info(nxdns): authority: database
|
||||
info(nxdns): authority: file (/etc/nxdns/config.zon)
|
||||
```
|
||||
|
||||
In file mode the reconcile prints what it changed before that line — per-table
|
||||
inserted (`+`), updated (`~`) and deleted (`-`) counts, the settings keys whose
|
||||
values changed, and any change to whether the admin password is set:
|
||||
|
||||
```
|
||||
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +45 ~0 -0;
|
||||
settings keys changed: dns.bind_ipv4 dns.port web.bind web.port …
|
||||
web authentication is now enabled
|
||||
```
|
||||
|
||||
A start whose file matches the database writes nothing and says so:
|
||||
|
||||
```
|
||||
reconciled '/etc/nxdns/config.zon': no changes
|
||||
```
|
||||
|
||||
Blocklist state is not declarative and survives every reconcile: a source whose
|
||||
URL the file still names keeps its row id, its checksum, its counters and its
|
||||
compiled `<id>.list` and `<id>.wild`, so a restart in file mode downloads
|
||||
nothing. Editing a source's URL is a new identity — a new row, a new id, and a
|
||||
fresh download.
|
||||
|
||||
### Failing to start in file mode
|
||||
|
||||
File mode fails closed. A file that is missing, unreadable, unparseable,
|
||||
oversized or invalid stops the start; nxdns never falls back to the database,
|
||||
because a fallback turns a deploy typo into a silently stale configuration.
|
||||
|
||||
```
|
||||
FAIL /etc/nxdns/config.zon: no such file
|
||||
nxdns run failed: ManagedConfigUnreadable
|
||||
run `nxdns check` to see the configuration in full
|
||||
```
|
||||
|
||||
That is exit 2, and `check --config` on the same path agrees. Only path-class
|
||||
open failures map that way — the file is not there, or the process may not read
|
||||
it. An open that fails for a reason a retry could clear, such as
|
||||
file-descriptor exhaustion or an I/O error, is exit 1: the box is wrong, not the
|
||||
configuration. See [exit codes](#exit-codes).
|
||||
|
||||
The exit-2 agreement covers `run --config` and `check --config`, which read the
|
||||
managed file through one shared helper. It does **not** extend to `import`'s
|
||||
positional argument: a missing file there is `import failed: FileNotFound`, exit
|
||||
1. That is deliberate rather than an oversight — the managed file is a
|
||||
declarative input an operator deploys, so its absence is a fact about the
|
||||
configuration, while `import`'s argument is a path typed at a prompt, and a
|
||||
mistyped path is a failed command rather than a verdict on anything.
|
||||
|
||||
Run `nxdns check --config FILE` before restarting anything that deploys a file.
|
||||
It grades every declarative fault `run` would hit — read, parse, size,
|
||||
validation — through the same code, which is what makes it a usable precondition
|
||||
in an Ansible handler.
|
||||
|
||||
## `check`
|
||||
|
||||
@@ -52,7 +122,7 @@ first. What it checks, in order:
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir DIR` | Data directory to look for `config.db` in. |
|
||||
| `--config FILE` | Check this file instead of the database. |
|
||||
| `--config FILE` | Grade this file instead of the database. |
|
||||
|
||||
### Failures and warnings
|
||||
|
||||
@@ -71,23 +141,38 @@ The last line is a summary, and it never contradicts the lines above it:
|
||||
|
||||
### Source selection
|
||||
|
||||
- `--config FILE` given explicitly: check that file, nothing else.
|
||||
- Otherwise, if `<data-dir>/config.db` exists: check the database — the right
|
||||
default, since the database is the truth on a configured server. It is opened
|
||||
immutable, so `check` writes nothing to it; see
|
||||
[what `check` does not do](#what-check-does-not-do).
|
||||
- Otherwise, if the default config file path exists: check it.
|
||||
- Otherwise: "nothing to check", exit 2.
|
||||
The flag decides, exactly as it does for `run`. There is no fallback and no
|
||||
probing of a default path:
|
||||
|
||||
The first line of output always names which source was checked. A file larger
|
||||
than 4 MiB fails with `larger than 4194304 bytes`; a ZON syntax error is
|
||||
- `--config FILE`: grade that file, and never open the database.
|
||||
- No `--config`: grade `<data-dir>/config.db`. It is opened immutable, so
|
||||
`check` writes nothing to it; see
|
||||
[what `check` does not do](#what-check-does-not-do).
|
||||
|
||||
So `nxdns check` and `nxdns check --config FILE` grade what the matching `run`
|
||||
invocation would serve. That is what makes `check` a pre-restart gate rather
|
||||
than an approximation of one.
|
||||
|
||||
A bare `check` on a box with no database says so and names both ways out of the
|
||||
state, exit 2:
|
||||
|
||||
```
|
||||
no config database at /var/lib/nxdns/config.db
|
||||
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
|
||||
```
|
||||
|
||||
The first line of output otherwise always names which source was checked. A file
|
||||
larger than 4 MiB fails with `larger than 4194304 bytes`; a ZON syntax error is
|
||||
reported with its line and column.
|
||||
|
||||
A named file that is missing or unreadable is a finding like any other, not an
|
||||
I/O failure that escapes the run: `FAIL <path>: no such file` or
|
||||
`FAIL <path>: not readable`, exit 2. That is the same code the implicit path
|
||||
gives when there is nothing to check, so naming the file does not change what a
|
||||
missing file costs.
|
||||
`FAIL <path>: not readable`, exit 2.
|
||||
|
||||
What `check --config` cannot see is the reconcile itself. It needs no database,
|
||||
so faults that only a write can produce — a disk that is full, a lock held by a
|
||||
restart that started first — are invisible to it. Those are runtime failures,
|
||||
exit 1, and they are not verdicts on the file.
|
||||
|
||||
### What `check` does not do
|
||||
|
||||
@@ -133,6 +218,15 @@ there. A directory that exists without a `config.db` is not that case: the
|
||||
database is opened with create semantics, so an empty `config.db` is created and
|
||||
migrated, and the export is of a default configuration.
|
||||
|
||||
The output is the same in both authority modes — nothing marks a file as
|
||||
exported from a file-mode box. That is what lets `export` be the adoption tool:
|
||||
the file you check is the file you deploy.
|
||||
|
||||
`web.password` is always written as `null` and `web.password_hash` carries the
|
||||
stored value, so an export re-imports without anyone knowing the password. A
|
||||
`null` password is not the same statement as an empty one: see
|
||||
[`web.password` and `web.password_hash`](configuration.md#password-and-hash).
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir DIR` | Data directory holding `config.db`. |
|
||||
@@ -142,25 +236,79 @@ See [back up and restore](../how-to/back-up-and-restore.md).
|
||||
|
||||
## `import FILE`
|
||||
|
||||
Validates FILE and replaces the whole configuration with it in one transaction.
|
||||
Prints every validation problem; a failed import leaves the database untouched.
|
||||
Refuses a database that already holds configuration unless `--force` is given
|
||||
(`DatabaseNotEmpty`, exit 2). The check measures what an operator set, not what
|
||||
the network did: client rows the DNS path materialised from traffic never
|
||||
trigger the refusal on their own. Creates the data directory at mode 0700 if it
|
||||
is missing.
|
||||
|
||||
Client history survives the replacement. An address the database already knew
|
||||
keeps its first-seen and last-seen even when FILE names it; only an address it
|
||||
has never seen takes the import's clock. A client FILE leaves out is removed,
|
||||
history included.
|
||||
Converges the database onto FILE in one transaction — the same reconcile a
|
||||
file-mode `run` performs, done once from the command line. Prints every
|
||||
validation problem; a failed import leaves the database untouched. Creates the
|
||||
data directory at mode 0700 if it is missing.
|
||||
|
||||
`FILE` is positional and may appear before or after the flags.
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir DIR` | Data directory holding `config.db` (created if missing). |
|
||||
| `--force` | Replace a database that already holds configuration. |
|
||||
| `--allow-delete` | Apply a file whose diff deletes rows. |
|
||||
|
||||
**`import` is a stop-first operation.** It rewrites configuration underneath a
|
||||
process that read it at startup, and a running server notices only some of it:
|
||||
filtering picks up the imported rows at the next reload, while upstreams,
|
||||
listeners and settings stay at their boot values until a restart.
|
||||
|
||||
### The delete gate
|
||||
|
||||
Rows the database holds and FILE does not name are deleted. That is the point of
|
||||
a declarative apply, and it is also how a mistaken `nxdns import ./wrong.zon`
|
||||
empties a configured server, so it takes a flag:
|
||||
|
||||
```
|
||||
FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it
|
||||
import failed: DestructiveImport
|
||||
```
|
||||
|
||||
That is exit 2, and the transaction rolls back. The message names every table
|
||||
with a non-zero delete count, so you can tell an intended pruning from a wrong
|
||||
file before applying anything.
|
||||
|
||||
An import that only adds rows, or only edits them, needs no flag. "Edit" here
|
||||
means a change to a row nxdns can still recognise as the same row. Each table
|
||||
has one column, or one tuple, that establishes identity:
|
||||
|
||||
| Table | Identity |
|
||||
| --- | --- |
|
||||
| `blocklist_sources`, `upstreams` | `url` |
|
||||
| `groups` | `name` |
|
||||
| `clients` | `ip` |
|
||||
| `client_prefixes` | `prefix` |
|
||||
| `forward_zones` | `zone` |
|
||||
| `local_records` | `(name, rtype, value)` |
|
||||
| `rules` | `(group, pattern, kind, action)` |
|
||||
|
||||
Change anything else on a row — a source's name, a group's `safe_search`, a
|
||||
client's group — and it is an edit, applied without a flag. Change the identity
|
||||
itself, such as renaming a group or correcting a typo in an upstream URL, and
|
||||
the engine sees a row that vanished and a row that appeared: that needs
|
||||
`--allow-delete`.
|
||||
|
||||
### What survives an import
|
||||
|
||||
Runtime state is not declarative and is preserved by identity, not by luck. A
|
||||
blocklist source whose URL is unchanged keeps its row id, its checksum, its
|
||||
counters and its compiled files, so an import costs no downloads. Clients the
|
||||
DNS path materialised from traffic are kept whole; naming one in the file
|
||||
promotes that row in place, keeping its first-seen and last-seen. Observed
|
||||
clients whose group the file no longer declares are moved to the `default`
|
||||
group rather than deleted with it, and none of that ever trips the delete gate.
|
||||
|
||||
### `import` against a file-mode box
|
||||
|
||||
It behaves like any other import. Nothing in the database records that a file
|
||||
governs it — authority lives in the invocation — so `import` neither detects nor
|
||||
refuses that case. The next restart's reconcile converges the database back to
|
||||
the file and its summary reports what it corrected. A source the import deleted
|
||||
comes back with a new row id, which means a fresh download of the whole list.
|
||||
|
||||
If a restart and an import race for the write lock, one of them simply wins: both
|
||||
take `BEGIN IMMEDIATE` under a 5-second busy timeout, so the outcome is an
|
||||
ordering, never a corrupted database.
|
||||
|
||||
## `version`
|
||||
|
||||
@@ -185,28 +333,44 @@ Code 2 means the same thing from every subcommand. `src/config/faults.zig`
|
||||
holds the one list of errors that mean "the configuration the operator supplied
|
||||
is wrong", and `run`, `check` and `import` all ask it, so a rejected file exits
|
||||
2 whichever command read it. The list is every error the validator raises, plus
|
||||
`ParseZon`, `ConfigTooLarge`, `NoUsableUpstreams` and `BadCertificate`. In
|
||||
practice that covers a seed file with a syntax error, one larger than 4 MiB, one
|
||||
with no `default` group (`MissingDefaultGroup`), one with no enabled upstream
|
||||
(`NoUpstreams`), a bad bind address, a bad rate limit, an unusable certificate,
|
||||
and `password` and `password_hash` set together.
|
||||
`ParseZon`, `ConfigTooLarge`, `NoUsableUpstreams`, `BadCertificate` and
|
||||
`ManagedConfigUnreadable`. In practice that covers a file with a syntax error,
|
||||
one larger than 4 MiB, one with no `default` group (`MissingDefaultGroup`), one
|
||||
with no enabled upstream (`NoUpstreams`), a bad bind address, a bad rate limit,
|
||||
an unusable certificate, `password` and `password_hash` set together, and a
|
||||
`--config` path that is absent or unreadable.
|
||||
|
||||
When `run` exits 2 it points at the diagnosis on stderr, whether the fault came
|
||||
from the seed file or from the database it loaded:
|
||||
The last of those is the one deliberate seam. A file nxdns cannot open is a
|
||||
configuration fault only when the *path* is the problem — the file is missing,
|
||||
permissions deny it, a path component is not a directory. Every other open
|
||||
failure, such as running out of file descriptors, is exit 1. The distinction
|
||||
earns its keep under the shipped systemd unit, which stops the service on exit 2
|
||||
rather than restarting it: a transient box fault graded as a configuration fault
|
||||
would take the resolver down until someone noticed.
|
||||
|
||||
When `run` exits 2 it points at the diagnosis on stderr, whichever authority the
|
||||
fault came from:
|
||||
|
||||
```
|
||||
run `nxdns check` to see the configuration in full
|
||||
```
|
||||
|
||||
`check` exits 2 for those faults and also when a probed upstream failed, when a
|
||||
named configuration file is missing or unreadable, when the database cannot be
|
||||
read or is not at this binary's schema version, and when there was nothing to
|
||||
check. Warnings never contribute.
|
||||
On a box with no configuration at all, `run` and `check` add the line that names
|
||||
both ways to get one:
|
||||
|
||||
`import` exits 2 for those faults and for `DatabaseNotEmpty`. That last one is
|
||||
deliberately not a configuration fault — it reports the state of the database
|
||||
rather than the content of a file — and `import` decides it for itself; the
|
||||
answer to it is `--force`, not an edit.
|
||||
```
|
||||
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
|
||||
```
|
||||
|
||||
`check` exits 2 for those faults and also when a probed upstream failed, when a
|
||||
named configuration file is missing or unreadable, and when the database cannot
|
||||
be read, is absent, or is not at this binary's schema version. Warnings never
|
||||
contribute.
|
||||
|
||||
`import` exits 2 for those faults and for `DestructiveImport`. That last one is
|
||||
deliberately not a configuration fault — it reports what applying the file would
|
||||
delete, rather than anything wrong with its content — and `import` decides it
|
||||
for itself; the answer to it is `--allow-delete`, not an edit.
|
||||
|
||||
`OutOfMemory` is exit 1 even when problems were recorded, because the report is
|
||||
then incomplete. Every other error is 1.
|
||||
|
||||
@@ -4,7 +4,7 @@ Every section, field and collection nxdns accepts, with its type, default,
|
||||
unit, validation rule and the subsystem that consumes it.
|
||||
|
||||
Source of truth: `src/config/model.zig` (the model and the defaults),
|
||||
`src/config/validate.zig` (the rules), `src/config/{bootstrap,import,export}.zig`
|
||||
`src/config/validate.zig` (the rules), `src/config/{loader,reconcile,import,export}.zig`
|
||||
(the lifecycle).
|
||||
|
||||
For how the file, the database and `export`/`import` relate to each other, see
|
||||
@@ -17,7 +17,7 @@ reference](cli.md).
|
||||
The file is ZON: a top-level anonymous struct whose fields are the sections and
|
||||
collections below. Enum values are ZON enum literals (`.level = .err`,
|
||||
`.response = .nxdomain`). Strings are double-quoted. The file may be at most
|
||||
4 MiB (`max_config_bytes` in `src/config/import.zig`); beyond that the error is
|
||||
4 MiB (`max_config_bytes` in `src/config/loader.zig`); beyond that the error is
|
||||
`ConfigTooLarge`. A syntax error is reported with its line and column.
|
||||
|
||||
Absent fields keep their defaults, both in the file and in the database. A
|
||||
@@ -38,7 +38,7 @@ Storage paths are process arguments, not configuration:
|
||||
| Argument | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `--data-dir DIR` | `/var/lib/nxdns` | Holds `config.db` and `querylog.db`; see [files and directories](files-and-directories.md). |
|
||||
| `--config FILE` | `/etc/nxdns/config.zon` | Names the seed file. |
|
||||
| `--config FILE` | — | On `run`, makes FILE the sole source of configuration; on `check`, grades FILE instead of the database. No default: without it the database is the configuration. |
|
||||
| `--web-dev DIR` | — | `run` only; serves the web interface from a directory instead of the embedded assets. |
|
||||
|
||||
## Scalar sections
|
||||
@@ -115,8 +115,8 @@ The web interface and REST API.
|
||||
| `web.enabled` | bool | true | — | — | gates the whole web stack: server, sessions, SSE hub, API limiter (`src/app.zig`) |
|
||||
| `web.bind` | string | `"0.0.0.0"` | IP address | must parse as an IP address of either family | web listener bind (`src/web/server.zig`) |
|
||||
| `web.port` | u16 | 8080 | port | 1–65535 (0 is refused) | web listener port |
|
||||
| `web.password` | string | `""` | — | must not be set together with `web.password_hash` | operator input only; hashed at import and discarded. Never a settings row — see [Password and hash](#password-and-hash) |
|
||||
| `web.password_hash` | string | `""` | — | — | argon2id PHC string verified at login (`src/web/auth.zig`); `""` disables authentication |
|
||||
| `web.password` | optional string | absent | — | must not be set together with `web.password_hash`; the empty string is refused | operator input only; hashed and discarded. Never a settings row — see [Password and hash](#password-and-hash) |
|
||||
| `web.password_hash` | optional string | absent | — | — | argon2id PHC string verified at login (`src/web/auth.zig`); `""` disables authentication, absent keeps the stored hash |
|
||||
| `web.session_ttl_hours` | u16 | 24 | hours | at least 1 | session expiry and cookie `Max-Age` (`src/web/auth.zig`) |
|
||||
| `web.api_rate_limit_per_min` | u32 | 300 | requests per minute | at least 1 | API token-bucket limiter (`src/web/api_limiter.zig`) |
|
||||
| `web.api_localhost_exempt` | bool | true | — | — | loopback requests skip the API limiter |
|
||||
@@ -341,27 +341,59 @@ deadline.
|
||||
|
||||
## Password and hash
|
||||
|
||||
Exactly one of `web.password` and `web.password_hash` may be set; setting both
|
||||
is refused (`PasswordAndHashBothSet` — ambiguity in a security setting).
|
||||
Both fields are optional, and the difference between *absent* and *empty* is the
|
||||
whole design. Absent means "keep whatever is stored". Empty means "there is no
|
||||
password".
|
||||
|
||||
- `web.password` is operator input only. At import time it is hashed with
|
||||
argon2id (OWASP parameters: t=2, m=19 MiB, p=1, PHC encoding) into
|
||||
`web.password_hash` and discarded. There is no `web.password` settings row,
|
||||
and `nxdns export` always writes `.password = ""`.
|
||||
| The file says | What happens to the stored hash |
|
||||
| --- | --- |
|
||||
| Neither field | Untouched. Authentication stays exactly as it was. |
|
||||
| `.password = "some-password"` | Verified against the stored hash; kept when it matches, replaced with a fresh hash when it does not. |
|
||||
| `.password = ""` | Refused, with a diagnostic naming the remedy. |
|
||||
| `.password_hash = "$argon2id$…"` | Written verbatim. |
|
||||
| `.password_hash = ""` | Cleared, which disables authentication. |
|
||||
| Both fields | Refused (`PasswordAndHashBothSet` — ambiguity in a security setting). |
|
||||
|
||||
Silence has to mean "keep", because the alternative is a trap. An operator who
|
||||
exports a configuration and trims the long PHC string out of it before
|
||||
committing the file to git means "leave the password alone", not "open the admin
|
||||
interface to the LAN". So disabling authentication takes the explicit empty
|
||||
string, and the empty *plaintext* — which would otherwise hash into a real hash
|
||||
that no login can ever satisfy — is refused outright:
|
||||
|
||||
```
|
||||
FAIL web.password: password is set to the empty string; omit the field to keep the stored password, or set password_hash = "" to disable authentication
|
||||
```
|
||||
|
||||
- `web.password` is operator input only. It is hashed with argon2id (OWASP
|
||||
parameters: t=2, m=19 MiB, p=1, PHC encoding) into `web.password_hash` and
|
||||
discarded. There is no `web.password` settings row, and `nxdns export` always
|
||||
writes `.password = null`.
|
||||
- `web.password_hash` is the stored argon2id PHC string. Supplying it directly,
|
||||
for example from a previous export, is how a backup restores authentication
|
||||
without knowing the password.
|
||||
- Both empty disables web authentication entirely.
|
||||
|
||||
Because the export carries the hash and re-importing an exported file takes the
|
||||
"password is empty" branch, the export/import round trip preserves the hash
|
||||
byte for byte. See [set up admin
|
||||
A plaintext password that has not changed is verified rather than re-hashed, so
|
||||
applying the same file twice leaves the same bytes in the database. That is what
|
||||
keeps the export/import round trip byte-stable with a password in the file. It
|
||||
costs a full argon2id computation either way — the verification is not a
|
||||
shortcut, and caching the plaintext to skip it would be a security bug.
|
||||
|
||||
Any change to whether a password is set is announced at startup, never left as a
|
||||
count:
|
||||
|
||||
```
|
||||
web authentication is now enabled
|
||||
web authentication is now disabled
|
||||
```
|
||||
|
||||
See [set up admin
|
||||
authentication](../how-to/set-up-admin-authentication.md).
|
||||
|
||||
## Validation errors
|
||||
|
||||
`nxdns check`, `nxdns import` and the `nxdns run` that seeds a database from the
|
||||
file all print one `FAIL path: message` line per problem, and report every
|
||||
`nxdns check`, `nxdns import` and a `nxdns run --config` that reads the file
|
||||
all print one `FAIL path: message` line per problem, and report every
|
||||
problem rather than the first. A finding that is legal but almost certainly
|
||||
unintended is prefixed `WARN` instead: it does not change the exit code, and it
|
||||
is printed by all three even when nothing failed, so an accepted configuration
|
||||
@@ -461,7 +493,7 @@ upstream. Everything else keeps its default.
|
||||
|
||||
.cache = .{ .size = 10000, .negative_ttl_max = 3600 },
|
||||
|
||||
// Web UI on 8080. The password is hashed at import and never stored;
|
||||
// Web UI on 8080. The password is hashed and never stored as plaintext;
|
||||
// leave .password_hash out when setting .password (they are exclusive).
|
||||
.web = .{
|
||||
.enabled = true,
|
||||
|
||||
@@ -10,11 +10,9 @@ snapshots), `src/platform/logging.zig` (the log file).
|
||||
Default `/var/lib/nxdns`, overridable with `--data-dir DIR`. `nxdns run` and
|
||||
`nxdns import` create it and its parents at mode 0700 when it is missing;
|
||||
`nxdns check` and `nxdns export` do not create it. `export` fails if it is not
|
||||
there. `check` opens it only on the branch that resolved to the database, so an
|
||||
absent data directory is not in itself a failure: an explicit `--config FILE`
|
||||
never looks at the directory, and without one `check` falls back to the default
|
||||
configuration file, or prints "nothing to check" and exits 2 when neither source
|
||||
exists.
|
||||
there. `check` opens it only when no `--config FILE` was given: with that flag it
|
||||
grades the file and never looks at the directory at all. Without it, an absent
|
||||
`config.db` is a failure naming the two ways to get one, exit 2.
|
||||
|
||||
`run`, `import` and `export` go through `DataDir.openConfigDb`, which opens
|
||||
`config.db` read/write, chmods it to 0600, enables WAL — creating
|
||||
@@ -39,7 +37,7 @@ older ones from the main file. That is the "uncheckpointed changes" failure in
|
||||
|
||||
| Path | What it is | Mode |
|
||||
| --- | --- | --- |
|
||||
| `config.db` | The configuration database — the single source of truth, including `web.password_hash`. | 0600 |
|
||||
| `config.db` | The configuration database, including `web.password_hash`. The source of truth in database mode; in file mode it is the runtime substrate the file is reconciled onto (see [the configuration file](#the-configuration-file)). | 0600 |
|
||||
| `config.db-wal`, `config.db-shm` | SQLite write-ahead log and shared-memory index for `config.db`. Created by `run`, `import` and `export` when WAL is enabled, inheriting the main file's permissions. `check` creates neither. | 0600 |
|
||||
| `querylog.db` | The query log: every domain every client asked for. Expendable — if it is missing or unusable it is recreated empty. | 0600 |
|
||||
| `querylog.db-wal`, `querylog.db-shm` | WAL sidecars for `querylog.db`. | 0600 |
|
||||
@@ -112,9 +110,20 @@ than being created world-readable.
|
||||
|
||||
## The configuration file
|
||||
|
||||
Default `/etc/nxdns/config.zon`, overridable with `--config FILE`. nxdns reads
|
||||
it and never writes it: it seeds an unconfigured database once and is ignored
|
||||
afterwards. nxdns does not create the file or its directory.
|
||||
There is no default path. `--config FILE` names the file, and without that flag
|
||||
no file is read at all — a `config.zon` sitting in `/etc/nxdns` that no
|
||||
invocation names is inert. `/etc/nxdns/config.zon` is a convention the packaging
|
||||
follows, not a location nxdns probes.
|
||||
|
||||
nxdns reads the file and never writes it, in either authority mode. It does not
|
||||
create the file or its directory either; the systemd unit's
|
||||
`ConfigurationDirectory=nxdns` creates `/etc/nxdns`, and the same unit's
|
||||
`ReadOnlyPaths=/etc/nxdns` denies the service write access to it, so the file
|
||||
cannot be modified by the process that reads it.
|
||||
|
||||
Under `run --config FILE` the file is the configuration and the database is the
|
||||
runtime substrate the server reads from: every start reconciles the one onto the
|
||||
other. So in that mode `config.db` is not the backup — the file is.
|
||||
|
||||
`nxdns export --out FILE` writes a ZON file at mode 0600 through a temporary
|
||||
file and a rename. That file carries `web.password_hash`, so treat exports as
|
||||
|
||||
+98
-36
@@ -9,8 +9,13 @@ delete the directory.
|
||||
Follow the steps in order. Each one says what it did.
|
||||
|
||||
Every command below was executed on x86_64 Linux with Zig 0.16.0, Node.js
|
||||
24.14.1, dig 9.20.26 and curl 8.21.0. The only thing substituted during that run
|
||||
was the tutorial directory.
|
||||
24.14.1, dig 9.20.26 and curl 8.21.0. Steps 4 to 11, 13 and 14 were re-run end
|
||||
to end for this revision, and the transcripts are that run's output with the
|
||||
tutorial directory substituted. Two things were not re-run: the browser page in
|
||||
step 12 — its endpoints were exercised, the page itself was not opened — and the
|
||||
two build commands in steps 1 and 2, which had already produced the binary under
|
||||
test. The ZON block at the end of step 14 was checked with `nxdns check
|
||||
--config` rather than started.
|
||||
|
||||
## What you need
|
||||
|
||||
@@ -30,7 +35,7 @@ cd web && npm ci && npm run build && cd ..
|
||||
```
|
||||
|
||||
This produces `web/dist`. Do not skip it. A plain `zig build` embeds
|
||||
`web/dist-placeholder`, a one-page status stub, and you would reach step 10 and
|
||||
`web/dist-placeholder`, a one-page status stub, and you would reach step 12 and
|
||||
find no admin interface there.
|
||||
|
||||
## 2. Build nxdns
|
||||
@@ -75,6 +80,11 @@ itself, so a configuration missing either one is rejected. Whichever command
|
||||
reads the file says the same thing and stops the same way: `run`, `nxdns check`
|
||||
and `nxdns import` all print the problem and exit 2.
|
||||
|
||||
This tutorial loads the file into the database once and then runs nxdns against
|
||||
the database, which is what the packaged systemd unit does. There is a second
|
||||
way to run nxdns, where the file itself stays the configuration; step 14 shows
|
||||
what changes.
|
||||
|
||||
## 4. Check the configuration before starting
|
||||
|
||||
```sh
|
||||
@@ -92,33 +102,44 @@ answers. It exits 2 when it found something that has to be fixed and 0
|
||||
otherwise. It writes nothing and starts no listener, so you can run it as often
|
||||
as you like.
|
||||
|
||||
## 5. Start the server
|
||||
|
||||
In your first terminal:
|
||||
## 5. Load it into the database
|
||||
|
||||
```sh
|
||||
zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data --config ~/nxdns-tutorial/config.zon
|
||||
zig-out/bin/nxdns import ~/nxdns-tutorial/config.zon --data-dir ~/nxdns-tutorial/data
|
||||
```
|
||||
|
||||
```
|
||||
info(migrations): config.db migrated from schema version 0 to 2
|
||||
info(config_bootstrap): seeded the database from '/home/you/nxdns-tutorial/config.zon'
|
||||
imported /home/you/nxdns-tutorial/config.zon
|
||||
```
|
||||
|
||||
The data directory did not exist; nxdns created it at mode 0700 along with
|
||||
`config.db`. From here the database holds the configuration, and you will change
|
||||
it through the API rather than by editing the file again.
|
||||
|
||||
## 6. Start the server
|
||||
|
||||
In your first terminal:
|
||||
|
||||
```sh
|
||||
zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data
|
||||
```
|
||||
|
||||
```
|
||||
info(querylog_schema): created querylog database '/home/you/nxdns-tutorial/data/querylog.db'
|
||||
info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 199 bytes
|
||||
info(nxdns): authority: database
|
||||
info(nxdns): nxdns <version> serving on udp [::1]:15353 udp 127.0.0.1:15353 tcp [::1]:15353 tcp 127.0.0.1:15353; 1 upstream(s); blocklist generation 1
|
||||
info(web_server): web interface listening on 127.0.0.1:8080
|
||||
```
|
||||
|
||||
The data directory did not exist; nxdns created it at mode 0700 along with
|
||||
`config.db` and `querylog.db`. The line that matters is `seeded the database
|
||||
from`: the configuration file was read into the database this once. From now on
|
||||
the database is the truth and the file is ignored on every later start, which
|
||||
you will see for yourself in step 11. Configuration changes go through the API,
|
||||
the web interface, or `nxdns import`.
|
||||
No `--config` here, and that is the point: `authority: database` says the
|
||||
database is the configuration and no file was opened at all. The file you wrote
|
||||
in step 3 has done its job.
|
||||
|
||||
Leave this terminal running and switch to the second one.
|
||||
|
||||
## 6. Resolve a name
|
||||
## 7. Resolve a name
|
||||
|
||||
```sh
|
||||
dig @127.0.0.1 -p 15353 example.com A +noall +answer
|
||||
@@ -131,10 +152,10 @@ example.com. 90 IN A 104.20.23.154
|
||||
|
||||
nxdns had no answer cached, so it forwarded the query to
|
||||
`https://cloudflare-dns.com/dns-query` over HTTPS and returned what came back.
|
||||
You now have a working resolver. It blocks nothing yet: the log line in step 5
|
||||
You now have a working resolver. It blocks nothing yet: the log line in step 6
|
||||
said `0 of 0 sources loaded`.
|
||||
|
||||
## 7. Add a blocklist source
|
||||
## 8. Add a blocklist source
|
||||
|
||||
```sh
|
||||
curl -s -X POST http://127.0.0.1:8080/api/blocklists \
|
||||
@@ -152,7 +173,7 @@ That is fine for a localhost tutorial and wrong for anything else; see
|
||||
|
||||
Note the `"id":1` in the response. You need it in the next step.
|
||||
|
||||
## 8. Attach the source to the `default` group
|
||||
## 9. Attach the source to the `default` group
|
||||
|
||||
A blocklist source belongs to the installation. Which groups use it is a
|
||||
separate decision, which is what lets one group get a strict list and another
|
||||
@@ -190,7 +211,7 @@ curl -s -X PUT http://127.0.0.1:8080/api/groups/1/sources \
|
||||
{"source_ids":[1]}
|
||||
```
|
||||
|
||||
## 9. Download the list
|
||||
## 10. Download the list
|
||||
|
||||
Adding a source registers it; it does not fetch it. Ask for a refresh:
|
||||
|
||||
@@ -199,25 +220,25 @@ curl -s -X POST http://127.0.0.1:8080/api/blocklists/update
|
||||
```
|
||||
|
||||
```json
|
||||
{"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1785683777,"last_success":1785683777,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":99277,"wildcards":0,"skipped_regex":0}]}
|
||||
{"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1786473715,"last_success":1786473715,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":99559,"wildcards":0,"skipped_regex":0}]}
|
||||
```
|
||||
|
||||
The download is about 3 MB and takes a few seconds. Watch the first terminal
|
||||
until this appears:
|
||||
|
||||
```
|
||||
info(blocklist_manager): blocklist snapshot generation 5: 1 of 1 sources loaded, 3088703 bytes
|
||||
info(blocklist_manager): blocklist snapshot generation 6: 1 of 1 sources loaded, 3096006 bytes
|
||||
```
|
||||
|
||||
`1 of 1 sources loaded` is the line to wait for. nxdns builds each blocklist
|
||||
snapshot in full and swaps it in atomically, so queries keep being answered from
|
||||
the previous snapshot the whole time the new one is being built. After this, the
|
||||
domain count in the JSON above — 99277 on the day this was run — is live.
|
||||
domain count in the JSON above — 99559 on the day this was run — is live.
|
||||
|
||||
From here on, the list is on disk under `~/nxdns-tutorial/data/blocklists`.
|
||||
Restarting nxdns does not re-download it.
|
||||
|
||||
## 10. Watch a domain get blocked
|
||||
## 11. Watch a domain get blocked
|
||||
|
||||
```sh
|
||||
dig @127.0.0.1 -p 15353 doubleclick.net A +noall +answer
|
||||
@@ -249,7 +270,7 @@ yourself can, with a wildcard pattern such as `*.doubleclick.net`. So when you
|
||||
pick a domain to test, pick one that is literally in the file.
|
||||
`www.google-analytics.com` is another that is.
|
||||
|
||||
## 11. Open the web interface
|
||||
## 12. Open the web interface
|
||||
|
||||
Visit <http://127.0.0.1:8080> in a browser. This is the single-page application
|
||||
you built in step 1, served out of the binary. The dashboard shows query and
|
||||
@@ -257,7 +278,7 @@ block counts, and the Blocklists page shows the source you added with its
|
||||
domain count. (The endpoints behind those two pages were checked while writing
|
||||
this; the browser page itself was not opened on the verification host.)
|
||||
|
||||
## 12. Stop it
|
||||
## 13. Stop it
|
||||
|
||||
In the first terminal, press Ctrl-C.
|
||||
|
||||
@@ -267,21 +288,61 @@ info(nxdns): shutting down
|
||||
|
||||
nxdns catches SIGINT and SIGTERM, stops serving and exits 0.
|
||||
|
||||
Start it again with the same command as in step 5 and read the first log line:
|
||||
Start it again with the same command as in step 6 and read the first log lines:
|
||||
|
||||
```
|
||||
info(config_bootstrap): configuration file ignored; the database is already configured
|
||||
info(blocklist_manager): blocklist snapshot generation 1: 1 of 1 sources loaded, 3088703 bytes
|
||||
info(blocklist_manager): blocklist snapshot generation 1: 1 of 1 sources loaded, 3096006 bytes
|
||||
info(nxdns): authority: database
|
||||
```
|
||||
|
||||
The configuration file was not even opened, and the blocklist came off disk
|
||||
rather than the network. The blocklist source, the group it is attached to, and
|
||||
the query log all survived the restart because they live in
|
||||
`~/nxdns-tutorial/data`.
|
||||
The blocklist came off disk rather than the network. The source you added, the
|
||||
group it is attached to, and the query log all survived the restart because they
|
||||
live in `~/nxdns-tutorial/data`, which is what `authority: database` means in
|
||||
practice.
|
||||
|
||||
Press Ctrl-C again to stop this second process. Nothing is listening on 15353 or
|
||||
8080 now, and nothing of nxdns is running.
|
||||
|
||||
## 14. See what the other mode does
|
||||
|
||||
You have been running in database mode. The alternative is to hand the same file
|
||||
back as the configuration, which is what `--config` means:
|
||||
|
||||
```sh
|
||||
zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data --config ~/nxdns-tutorial/config.zon
|
||||
```
|
||||
|
||||
```
|
||||
reconciled '/home/you/nxdns-tutorial/config.zon': sources +0 ~0 -1; group_sources +0 ~0 -1;
|
||||
info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 199 bytes
|
||||
info(nxdns): authority: file (/home/you/nxdns-tutorial/config.zon)
|
||||
```
|
||||
|
||||
**Read that first line.** The blocklist source is gone. That is not a bug — it is
|
||||
the whole contract. The file you wrote in step 3 never mentioned a blocklist
|
||||
source, and in file mode the file is the complete statement of what the
|
||||
configuration is, so anything the database holds that the file does not name is
|
||||
removed at every start. The reconcile said so in one line before doing it.
|
||||
|
||||
The compiled list is still on disk and the query log is untouched; what changed
|
||||
is the configuration, and it now matches the file exactly.
|
||||
|
||||
Neither mode is the "advanced" one. Database mode suits a box someone
|
||||
administers through the web interface. File mode suits a file kept in git and
|
||||
deployed by a tool, where the deployed file being what is running matters more
|
||||
than clicking. To have kept the blocklist here, you would put it in the file:
|
||||
|
||||
```zon
|
||||
.blocklist_sources = .{
|
||||
.{ .url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", .name = "StevenBlack hosts" },
|
||||
},
|
||||
.group_sources = .{
|
||||
.{ .group = "default", .source_url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts" },
|
||||
},
|
||||
```
|
||||
|
||||
Ctrl-C to stop it.
|
||||
|
||||
## What you have now
|
||||
|
||||
A resolver that answers real queries, a real blocklist of about 99000 domains
|
||||
@@ -292,9 +353,10 @@ directory you can delete:
|
||||
rm -rf ~/nxdns-tutorial
|
||||
```
|
||||
|
||||
You also saw the two things that surprise people most: the configuration file
|
||||
seeds the database once and is ignored afterwards, and a blocklist source does
|
||||
nothing until a group uses it.
|
||||
You also saw the three things that surprise people most: a blocklist source
|
||||
does nothing until a group uses it, which authority a start runs under is
|
||||
printed rather than guessed, and in file mode anything the file does not name is
|
||||
removed at the next start.
|
||||
|
||||
## Where to go next
|
||||
|
||||
@@ -305,4 +367,4 @@ nothing until a group uses it.
|
||||
- [reference/configuration.md](../reference/configuration.md) — every field you
|
||||
did not set.
|
||||
- [explanation/configuration-model.md](../explanation/configuration-model.md) —
|
||||
why the file seeds and the database rules.
|
||||
why there are two authority modes and what each one is for.
|
||||
|
||||
+49
-22
@@ -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
@@ -32,7 +32,6 @@ const tls = std.crypto.tls;
|
||||
|
||||
const api_limiter = @import("web/api_limiter.zig");
|
||||
const auth = @import("web/auth.zig");
|
||||
const bootstrap = @import("config/bootstrap.zig");
|
||||
const cert_store = @import("server/cert_store.zig");
|
||||
const cli = @import("cli.zig");
|
||||
const clients = @import("server/clients.zig");
|
||||
@@ -49,6 +48,7 @@ const fetcher = @import("filter/fetcher.zig");
|
||||
const forward_zones = @import("local/forward_zones.zig");
|
||||
const handler = @import("server/handler.zig");
|
||||
const http_util = @import("web/http_util.zig");
|
||||
const loader = @import("config/loader.zig");
|
||||
const local_records = @import("local/records.zig");
|
||||
const local_tables = @import("server/local_tables.zig");
|
||||
const logger_mod = @import("storage/logger.zig");
|
||||
@@ -60,6 +60,7 @@ const pause = @import("server/pause.zig");
|
||||
const pool_mod = @import("upstream/pool.zig");
|
||||
const query_sink = @import("server/query_sink.zig");
|
||||
const rate_limiter = @import("server/rate_limiter.zig");
|
||||
const reconcile = @import("config/reconcile.zig");
|
||||
const retention_mod = @import("storage/retention.zig");
|
||||
const safe_url = @import("safe_url.zig");
|
||||
const shutdown = @import("server/shutdown.zig");
|
||||
@@ -95,6 +96,12 @@ pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 {
|
||||
const mapped = failureExitCode(err);
|
||||
if (mapped == cli.exit_check) {
|
||||
runner.err.writeAll("run `nxdns check` to see the configuration in full\n") catch {};
|
||||
// Ruling 6: after bootstrap seeding died, a fresh database fails
|
||||
// validation naturally (`NoUsableUpstreams`) and the operator needs
|
||||
// to be told how a database gets a configuration at all. In file
|
||||
// mode they already have a file, and the diagnostics above name what
|
||||
// is wrong with it.
|
||||
if (args.config == null) cli.writeDbSourceHint(runner.err);
|
||||
}
|
||||
break :code mapped;
|
||||
};
|
||||
@@ -113,70 +120,201 @@ fn failureExitCode(err: anyerror) u8 {
|
||||
return if (faults.isConfigFault(err)) cli.exit_check else cli.exit_runtime;
|
||||
}
|
||||
|
||||
/// First run only: the file seeds an empty database and is ignored forever
|
||||
/// after. Its diagnostics are the operator's one chance to see what the file
|
||||
/// said, so they are printed the way `check` and `import` print them.
|
||||
/// File authority (ruling 2): read the file the operator named, validate it, and
|
||||
/// converge the database onto it — on this start and on every start after it.
|
||||
/// Returns the wall clock the reconcile ran at, which becomes the settings
|
||||
/// envelope's `reconciled_at`.
|
||||
///
|
||||
/// Printed on the way out either way. A seed file can be accepted and still
|
||||
/// carry warnings — a blocklist source in no group is downloaded and compiled
|
||||
/// into nothing — and a warning that only appears when the start fails is a
|
||||
/// warning nobody ever reads: the start it describes is the one that worked.
|
||||
/// `check` reported it and `run` did not, which left the same file graded two
|
||||
/// ways.
|
||||
/// A missing, unreadable or invalid file fails the start. There is no fallback
|
||||
/// to the database under any failure: a fallback turns a deploy typo into a
|
||||
/// silently stale configuration, which is the failure mode the whole mode exists
|
||||
/// to prevent.
|
||||
///
|
||||
/// The runner's error writer, not `std.log`: this runs before
|
||||
/// `logging.install`, and one rendering of a diagnostic across `run`, `check`
|
||||
/// and `import` is the point of `Diagnostics.writeAll`.
|
||||
/// Diagnostics are printed on the way out either way. A file can be accepted and
|
||||
/// still carry warnings — a blocklist source in no group is downloaded and
|
||||
/// compiled into nothing — and a warning that only appears when the start fails
|
||||
/// is a warning nobody ever reads: the start it describes is the one that
|
||||
/// worked.
|
||||
///
|
||||
/// Flushed here rather than left to `run`'s exit flush. That writer is buffered
|
||||
/// (`main` gives it 4 KiB) and `serve` does not return for as long as the
|
||||
/// service runs, so a line left in the buffer reaches the operator when the
|
||||
/// The runner's writers, not `std.log`: this runs before `logging.install`, and
|
||||
/// one rendering of a diagnostic across `run`, `check` and `import` is the point
|
||||
/// of `Diagnostics.writeAll`.
|
||||
///
|
||||
/// Flushed here rather than left to `run`'s exit flush. Both writers are
|
||||
/// buffered (`main` gives them 4 KiB) and `serve` does not return for as long as
|
||||
/// the service runs, so a line left in the buffer reaches the operator when the
|
||||
/// process stops — days after the start it describes. A failure path flushes
|
||||
/// anyway because it returns immediately; the successful start is the one that
|
||||
/// needs this.
|
||||
fn seedFromFile(
|
||||
fn reconcileFromFile(
|
||||
r: cli.Runner,
|
||||
config_db: *db.Db,
|
||||
dir: std.Io.Dir,
|
||||
config_path: []const u8,
|
||||
) bootstrap.Error!bootstrap.Outcome {
|
||||
) !i64 {
|
||||
return reconcileFromFileAt(
|
||||
r,
|
||||
config_db,
|
||||
dir,
|
||||
config_path,
|
||||
std.Io.Clock.real.now(r.io).toSeconds(),
|
||||
);
|
||||
}
|
||||
|
||||
/// `pass_now` is what the engine stamps into the runtime columns of the rows it
|
||||
/// inserts (`first_seen`, `last_seen`, `created_at`), and it is deliberately not
|
||||
/// the value this returns.
|
||||
///
|
||||
/// The two clocks answer different questions, and conflating them was a real
|
||||
/// defect: `reconciled_at` means "this process loaded the file at T" and is
|
||||
/// compared against the file's mtime to detect a restart-pending state
|
||||
/// (ruling 7). A stamp taken *before* the read makes a file written during the
|
||||
/// read look newer than the process that loaded it — a false "restart pending"
|
||||
/// in the UI for a file that is fully applied. So this returns a clock read
|
||||
/// taken immediately after the commit, and `pass_now` never leaves the engine.
|
||||
///
|
||||
/// Split from `reconcileFromFile` so the two are separable in a test: pin
|
||||
/// `pass_now` and the returned stamp must still be the real clock.
|
||||
fn reconcileFromFileAt(
|
||||
r: cli.Runner,
|
||||
config_db: *db.Db,
|
||||
dir: std.Io.Dir,
|
||||
config_path: []const u8,
|
||||
pass_now: i64,
|
||||
) !i64 {
|
||||
var arena_state: std.heap.ArenaAllocator = .init(r.gpa);
|
||||
defer arena_state.deinit();
|
||||
|
||||
var diags: validate.Diagnostics = .init(r.gpa);
|
||||
defer diags.deinit();
|
||||
|
||||
const result = bootstrap.bootstrap(r.io, r.gpa, config_db, dir, config_path, &diags);
|
||||
const result = applyManagedFile(r, config_db, dir, config_path, arena_state.allocator(), &diags, pass_now);
|
||||
|
||||
// Neither discard is an oversight, and the two answer different questions.
|
||||
//
|
||||
// On a rejected seed file, `result` is returned untouched: the operator gets
|
||||
// the reason the start failed, never a writer error standing in front of it.
|
||||
// A broken stderr is not why the configuration was refused.
|
||||
//
|
||||
// On a seed that worked, a failure here does not stop the start. The trade
|
||||
// is one lost warning line against a household with no name resolution, and
|
||||
// `run` before this point is the only stretch of this program where an
|
||||
// output failure could take DNS down at all — ruling 4 already says nothing
|
||||
// after it is fatal. Nor could the failure be reported: this writer *is* the
|
||||
// error channel, and `logging.install` has not run yet, so `std.log` resolves
|
||||
// to the same stderr a diagnostic about it would have to travel down.
|
||||
//
|
||||
// It is not lost from the process either. A failed drain consumes nothing,
|
||||
// so whatever the buffer held it still holds — that half is observed, in
|
||||
// "a broken error writer does not stop a first start that succeeded" below,
|
||||
// which reads the retained warning back out of the same writer.
|
||||
//
|
||||
// What happens to those bytes afterwards is derived, not watched, and is
|
||||
// labelled so deliberately. `Io.Writer.defaultFlush` drains while `end != 0`
|
||||
// and `run`'s exit flush maps a failure to exit 1, so a stderr still broken
|
||||
// at shutdown should carry the condition out in the exit code, and one that
|
||||
// recovered should deliver the line late. No test drives `run` that far.
|
||||
// Two limits come with the derivation: an empty buffer flushes clean and
|
||||
// reports nothing at all, and a failure that recovers ends at exit 0 with a
|
||||
// line the operator reads days after the start it describes.
|
||||
// Both discards are deliberate, and they answer different questions. On a
|
||||
// rejected file the operator gets the reason the start failed, never a
|
||||
// writer error standing in front of it — a broken stderr is not why the
|
||||
// configuration was refused. On a file that applied, a failure here does not
|
||||
// stop the start: the trade is one lost warning line against a household
|
||||
// with no name resolution, and this writer *is* the error channel, so the
|
||||
// failure has nowhere to be reported anyway.
|
||||
diags.writeAll(r.err) catch {};
|
||||
r.err.flush() catch {};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Returns the moment the transaction committed, which is what the settings
|
||||
/// envelope reports as `reconciled_at`.
|
||||
fn applyManagedFile(
|
||||
r: cli.Runner,
|
||||
config_db: *db.Db,
|
||||
dir: std.Io.Dir,
|
||||
config_path: []const u8,
|
||||
arena: Allocator,
|
||||
diags: *validate.Diagnostics,
|
||||
now: i64,
|
||||
) !i64 {
|
||||
const cfg = try loader.load(r.io, arena, dir, config_path, diags);
|
||||
try validate.validate(cfg, diags);
|
||||
|
||||
// The keys this pass wrote, never their values (ruling 8). Duplicated into
|
||||
// `gpa` by the engine, so this frame frees them.
|
||||
var changed: std.ArrayList([]const u8) = .empty;
|
||||
defer {
|
||||
for (changed.items) |key| r.gpa.free(key);
|
||||
changed.deinit(r.gpa);
|
||||
}
|
||||
|
||||
var pass = reconcile.begin(r.io, r.gpa, config_db, cfg, now, .{
|
||||
.changed_settings = &changed,
|
||||
}) catch |err| {
|
||||
// `begin` has already rolled its own transaction back. What the operator
|
||||
// needs is the cause: a full SD card must read as "disk", not as a bare
|
||||
// exit 1, so the SQLite condition is named.
|
||||
reportReconcileFailure(r, config_db, config_path, err);
|
||||
return err;
|
||||
};
|
||||
errdefer pass.rollback();
|
||||
|
||||
pass.commit() catch |err| {
|
||||
reportReconcileFailure(r, config_db, config_path, err);
|
||||
return err;
|
||||
};
|
||||
|
||||
// Read here and nowhere earlier: the file is loaded once this line runs, and
|
||||
// not one statement before it.
|
||||
const reconciled_at = std.Io.Clock.real.now(r.io).toSeconds();
|
||||
|
||||
printSummary(r, config_path, pass.summary, changed.items) catch {};
|
||||
return reconciled_at;
|
||||
}
|
||||
|
||||
/// SQLite conditions an operator acts on differently. `@errorName` alone would
|
||||
/// say `Full`, which is not a word anyone can search for; the primary result
|
||||
/// code's own name is.
|
||||
fn sqliteCodeName(err: anyerror) ?[]const u8 {
|
||||
return switch (err) {
|
||||
error.Full => "SQLITE_FULL",
|
||||
error.Busy => "SQLITE_BUSY",
|
||||
error.IoErr => "SQLITE_IOERR",
|
||||
error.ReadOnly => "SQLITE_READONLY",
|
||||
error.Corrupt => "SQLITE_CORRUPT",
|
||||
error.Constraint => "SQLITE_CONSTRAINT",
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
fn reportReconcileFailure(r: cli.Runner, config_db: *db.Db, config_path: []const u8, err: anyerror) void {
|
||||
var buf: [256]u8 = undefined;
|
||||
const detail = config_db.lastError(&buf);
|
||||
const code = sqliteCodeName(err) orelse @errorName(err);
|
||||
r.err.print("reconciling '{s}' failed: {s}: {s}\n", .{ config_path, code, detail }) catch {};
|
||||
r.err.flush() catch {};
|
||||
}
|
||||
|
||||
/// What that restart changed, without opening sqlite (ruling 8): per-table
|
||||
/// counts, the settings keys that moved — never their values — and an
|
||||
/// authentication change, which is never a silent line item in a count.
|
||||
fn printSummary(
|
||||
r: cli.Runner,
|
||||
config_path: []const u8,
|
||||
summary: reconcile.Summary,
|
||||
changed_settings: []const []const u8,
|
||||
) !void {
|
||||
try r.out.print("reconciled '{s}':", .{config_path});
|
||||
if (summary.isNoOp()) {
|
||||
try r.out.writeAll(" no changes\n");
|
||||
} else {
|
||||
inline for (@typeInfo(reconcile.Summary).@"struct".fields) |field| {
|
||||
if (field.type == reconcile.TableCounts) {
|
||||
const counts = @field(summary, field.name);
|
||||
if (counts.total() != 0) {
|
||||
try r.out.print(" {s} +{d} ~{d} -{d};", .{
|
||||
field.name,
|
||||
counts.inserted,
|
||||
counts.updated,
|
||||
counts.deleted,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
try r.out.writeAll("\n");
|
||||
|
||||
if (changed_settings.len != 0) {
|
||||
try r.out.writeAll("settings keys changed:");
|
||||
for (changed_settings) |key| try r.out.print(" {s}", .{key});
|
||||
try r.out.writeAll("\n");
|
||||
}
|
||||
switch (summary.auth_transition) {
|
||||
.none => {},
|
||||
.enabled => try r.out.writeAll("web authentication is now enabled\n"),
|
||||
.disabled => try r.out.writeAll("web authentication is now disabled\n"),
|
||||
.rotated => try r.out.writeAll("the web password changed\n"),
|
||||
}
|
||||
}
|
||||
try r.out.flush();
|
||||
}
|
||||
|
||||
fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
const io = r.io;
|
||||
const gpa = r.gpa;
|
||||
@@ -193,7 +331,15 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
defer config_db.close();
|
||||
_ = try migrations.migrate(&config_db);
|
||||
|
||||
_ = try seedFromFile(r, &config_db, std.Io.Dir.cwd(), paths.config);
|
||||
// Ruling 1: the presence of `--config` is the whole authority decision. With
|
||||
// it, the file is the sole declarative source and the database is converged
|
||||
// onto it here, before anything reads the database. Without it the database
|
||||
// is authority and this step does not exist — a file on disk that no flag
|
||||
// names changes nothing.
|
||||
const reconciled_at: ?i64 = if (args.config) |config_path|
|
||||
try reconcileFromFile(r, &config_db, std.Io.Dir.cwd(), config_path)
|
||||
else
|
||||
null;
|
||||
|
||||
// Every string in `cfg` points into this arena, and the pool's endpoints,
|
||||
// the handler's records and the monitor's paths all keep such strings. It
|
||||
@@ -203,6 +349,17 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
// The web layer reads the managed path out of `WebState` for the whole life
|
||||
// of the process, so it takes a copy from the arena that outlives it rather
|
||||
// than borrowing `argv`.
|
||||
const authority: web_server.Authority = if (args.config) |config_path|
|
||||
.{ .managed_file = try arena.dupe(u8, config_path) }
|
||||
else
|
||||
.database;
|
||||
|
||||
// The database stays the runtime substrate and the effective-config read
|
||||
// path in both modes: in file mode the reconcile above has just made it
|
||||
// agree with the file.
|
||||
const cfg = try config_export.readConfig(&config_db, arena);
|
||||
|
||||
// From here on `std.log` goes wherever the operator asked. Before this call
|
||||
@@ -472,7 +629,9 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
if (cfg.web.enabled) web_state = .{
|
||||
.gpa = gpa,
|
||||
.web = cfg.web,
|
||||
.live_hash = .init(cfg.web.password_hash),
|
||||
.authority = authority,
|
||||
.reconciled_at = reconciled_at,
|
||||
.live_hash = .init(cfg.web.password_hash orelse ""),
|
||||
.handler = &h,
|
||||
.pause = &paused,
|
||||
.tracker = &tracker,
|
||||
@@ -602,7 +761,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
// this box exists for — keeps serving.
|
||||
if (cfg.web.enabled) try group.concurrent(io, web_server.serve, .{ &web_state, io });
|
||||
|
||||
logStartup(io, &manager, upstreams.active().len, .{
|
||||
logStartup(io, authority, &manager, upstreams.active().len, .{
|
||||
.udp6 = if (udp6) |*s| s.boundAddress() else null,
|
||||
.udp4 = if (udp4) |*s| s.boundAddress() else null,
|
||||
.tcp6 = if (tcp6) |*s| s.boundAddress() else null,
|
||||
@@ -1005,8 +1164,8 @@ test "run, check and import agree on a seed file with no default group" {
|
||||
\\}
|
||||
;
|
||||
|
||||
// `run`: `serve` seeds through `bootstrap`, which is a wrapper over this
|
||||
// exact call, so this is the error `run` classifies.
|
||||
// `run --config`: `serve` validates through this exact call before it
|
||||
// reconciles, so this is the error `run` classifies.
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
defer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
@@ -1082,11 +1241,11 @@ test "a configuration whose blocklist source is in no group imports and checks c
|
||||
try std.testing.expectEqual(@as(usize, 1), check_diags.warningCount());
|
||||
}
|
||||
|
||||
test "a first start that seeds from a file prints the warnings the file earned" {
|
||||
// D5, second half. The seed file is read once in the life of a database, so
|
||||
// a warning it earns is printed on that start or never. `run` printed
|
||||
// diagnostics only when the file was rejected, which made a successful first
|
||||
// start the one place the finding could not surface.
|
||||
test "a start in file mode prints the warnings the file earned" {
|
||||
// D5, second half. `run` printed diagnostics only when the file was
|
||||
// rejected, which made a successful start the one place a finding could not
|
||||
// surface. Under file authority the file is read on every start, so this is
|
||||
// the line an operator sees after every restart, not only the first.
|
||||
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
@@ -1119,11 +1278,10 @@ test "a first start that seeds from a file prints the warnings the file earned"
|
||||
var err_writer = err_file.writer(io, &err_buf);
|
||||
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface };
|
||||
|
||||
// The file is valid, so the start succeeds and the database is seeded.
|
||||
try std.testing.expectEqual(
|
||||
bootstrap.Outcome.seeded,
|
||||
try seedFromFile(r, &database, tmp.dir, "config.zon"),
|
||||
);
|
||||
// The file is valid, so the start succeeds and the database converges onto
|
||||
// it. The returned stamp is what the settings envelope reports.
|
||||
const reconciled_at = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
|
||||
try std.testing.expect(reconciled_at > 0);
|
||||
try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
|
||||
|
||||
// Nothing flushes here on purpose. In production `serve` runs from this
|
||||
@@ -1138,6 +1296,164 @@ test "a first start that seeds from a file prints the warnings the file earned"
|
||||
try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "FAIL"));
|
||||
}
|
||||
|
||||
test "run with a missing managed file exits 2 with the path, and never serves from the database" {
|
||||
// Ruling 2: file mode fails closed. The database below is a perfectly good
|
||||
// one — migrated, and the run would have reached the listeners on it in db
|
||||
// mode — so a fallback would show up here as exit 0.
|
||||
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
const gpa = std.testing.allocator;
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
var data_buf: [160]u8 = undefined;
|
||||
const data_dir = try std.fmt.bufPrint(&data_buf, ".zig-cache/tmp/{s}/data", .{tmp.sub_path});
|
||||
var missing_buf: [160]u8 = undefined;
|
||||
const missing = try std.fmt.bufPrint(&missing_buf, ".zig-cache/tmp/{s}/nope.zon", .{tmp.sub_path});
|
||||
|
||||
// Real buffered `File.Writer`s, not `Writer.fixed`: this asserts the
|
||||
// operator received the lines, and a fixed writer's flush is a no-op that
|
||||
// counts a buffered line as delivered.
|
||||
var out_file = try tmp.dir.createFile(io, "stdout.txt", .{});
|
||||
defer out_file.close(io);
|
||||
var err_file = try tmp.dir.createFile(io, "stderr.txt", .{});
|
||||
defer err_file.close(io);
|
||||
var out_buf: [4096]u8 = undefined;
|
||||
var err_buf: [4096]u8 = undefined;
|
||||
var out_writer = out_file.writer(io, &out_buf);
|
||||
var err_writer = err_file.writer(io, &err_buf);
|
||||
const r: cli.Runner = .{
|
||||
.io = io,
|
||||
.gpa = gpa,
|
||||
.out = &out_writer.interface,
|
||||
.err = &err_writer.interface,
|
||||
};
|
||||
|
||||
try std.testing.expectEqual(cli.exit_check, run(r, .{
|
||||
.paths = .{ .data_dir = data_dir },
|
||||
.config = missing,
|
||||
}));
|
||||
|
||||
const printed = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192));
|
||||
defer gpa.free(printed);
|
||||
// The path is in the message: an error name alone tells the operator nothing
|
||||
// about which file the deploy got wrong.
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, missing));
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "no such file"));
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "nxdns check"));
|
||||
// The db-mode remediation hint belongs to db mode: in file mode the operator
|
||||
// has a file, and the diagnostic above says what is wrong with it.
|
||||
try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "make a file the source of truth"));
|
||||
}
|
||||
|
||||
test "reconciled_at is stamped after the commit, not from the clock the pass wrote with" {
|
||||
// Ruling 7: `reconciled_at` means "this process loaded the file at T", and
|
||||
// the UI compares it against the file's mtime to say whether a restart is
|
||||
// pending. A stamp taken before the read makes a file written while the read
|
||||
// ran look newer than the process that loaded it — a restart-pending banner
|
||||
// over a configuration that is fully applied.
|
||||
//
|
||||
// The pass clock is pinned to 1970 here, which the engine really does use:
|
||||
// the inserted client below carries it. If the two were one value, the
|
||||
// returned stamp would be 1970 too.
|
||||
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
const gpa = std.testing.allocator;
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
|
||||
\\.{
|
||||
\\ .groups = .{ .{ .name = "default" } },
|
||||
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
|
||||
\\ .clients = .{ .{ .ip = "192.168.1.5", .name = "tablet" } },
|
||||
\\}
|
||||
});
|
||||
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
defer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
_ = try migrations.migrate(&database);
|
||||
|
||||
var out_buf: [4096]u8 = undefined;
|
||||
var err_buf: [1024]u8 = undefined;
|
||||
var out: Writer = .fixed(&out_buf);
|
||||
var err: Writer = .fixed(&err_buf);
|
||||
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err };
|
||||
|
||||
const pass_now: i64 = 42;
|
||||
const before = std.Io.Clock.real.now(io).toSeconds();
|
||||
const reconciled_at = try reconcileFromFileAt(r, &database, tmp.dir, "config.zon", pass_now);
|
||||
|
||||
// The pinned clock reached the engine, so the two values really are separate
|
||||
// inputs rather than the same read twice.
|
||||
try std.testing.expectEqual(pass_now, try database.queryInt(
|
||||
"SELECT first_seen FROM clients WHERE ip = '192.168.1.5'",
|
||||
));
|
||||
try std.testing.expect(reconciled_at != pass_now);
|
||||
try std.testing.expect(reconciled_at >= before);
|
||||
}
|
||||
|
||||
test "the startup summary reports what the reconcile changed, then that nothing changed" {
|
||||
// Ruling 8: the answer to "what did that restart change" without opening
|
||||
// sqlite. Also ruling 5 from the operator's side — the second start of an
|
||||
// unchanged file says so.
|
||||
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
const gpa = std.testing.allocator;
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
|
||||
\\.{
|
||||
\\ .groups = .{ .{ .name = "default" } },
|
||||
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
|
||||
\\ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" },
|
||||
\\}
|
||||
});
|
||||
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
defer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
_ = try migrations.migrate(&database);
|
||||
|
||||
var out_file = try tmp.dir.createFile(io, "stdout.txt", .{});
|
||||
defer out_file.close(io);
|
||||
var out_buf: [4096]u8 = undefined;
|
||||
var out_writer = out_file.writer(io, &out_buf);
|
||||
var err_buf: [1024]u8 = undefined;
|
||||
var err: Writer = .fixed(&err_buf);
|
||||
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out_writer.interface, .err = &err };
|
||||
|
||||
_ = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
|
||||
{
|
||||
// Read back through the file: `serve` does not return for as long as the
|
||||
// service runs, so a summary still in the buffer is a summary nobody
|
||||
// reads.
|
||||
const printed = try tmp.dir.readFileAlloc(io, "stdout.txt", gpa, .limited(8192));
|
||||
defer gpa.free(printed);
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "reconciled 'config.zon':"));
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "upstreams +1 ~0 -0"));
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "settings keys changed:"));
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "web.password_hash"));
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "authentication is now enabled"));
|
||||
// The keys, never the values: the hash the file set must not be echoed.
|
||||
try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "$argon2id$"));
|
||||
}
|
||||
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "stdout.txt", .data = "" });
|
||||
_ = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
|
||||
{
|
||||
const printed = try tmp.dir.readFileAlloc(io, "stdout.txt", gpa, .limited(8192));
|
||||
defer gpa.free(printed);
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "no changes"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A broken stderr, in the shape `main` builds: a buffered `File.Writer`, with
|
||||
/// its drain switched to the mode that fails. `Writer.fixed` cannot stand in —
|
||||
/// its flush is `noopFlush`, so it has no failure to report and its `written()`
|
||||
@@ -1151,8 +1467,8 @@ fn brokenErrWriter(io: std.Io, file: std.Io.File, buffer: []u8) std.Io.File.Writ
|
||||
return w;
|
||||
}
|
||||
|
||||
test "a broken error writer does not replace the reason a seed file was rejected" {
|
||||
// The operator has to see why seeding failed, and a broken stderr is not
|
||||
test "a broken error writer does not replace the reason a managed file was rejected" {
|
||||
// The operator has to see why the start failed, and a broken stderr is not
|
||||
// that reason.
|
||||
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
@@ -1187,7 +1503,7 @@ test "a broken error writer does not replace the reason a seed file was rejected
|
||||
|
||||
try std.testing.expectError(
|
||||
error.MissingDefaultGroup,
|
||||
seedFromFile(r, &database, tmp.dir, "config.zon"),
|
||||
reconcileFromFile(r, &database, tmp.dir, "config.zon"),
|
||||
);
|
||||
|
||||
// Empty, so the writer did fail — without this the assertion above would
|
||||
@@ -1197,7 +1513,7 @@ test "a broken error writer does not replace the reason a seed file was rejected
|
||||
try std.testing.expectEqual(@as(usize, 0), printed.len);
|
||||
}
|
||||
|
||||
test "a broken error writer does not stop a first start that succeeded" {
|
||||
test "a broken error writer does not stop a start whose file applied" {
|
||||
// The call this file makes: a DNS server for a household does not refuse to
|
||||
// resolve because stderr is broken. What it must not do is drop the warning
|
||||
// on the floor, so the second half checks the buffer still holds it.
|
||||
@@ -1233,10 +1549,7 @@ test "a broken error writer does not stop a first start that succeeded" {
|
||||
var err_writer = brokenErrWriter(io, err_file, &err_buf);
|
||||
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface };
|
||||
|
||||
try std.testing.expectEqual(
|
||||
bootstrap.Outcome.seeded,
|
||||
try seedFromFile(r, &database, tmp.dir, "config.zon"),
|
||||
);
|
||||
_ = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
|
||||
try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
|
||||
|
||||
// Nothing reached the file, so the flush really did fail.
|
||||
@@ -1289,7 +1602,20 @@ fn isWildcard(addr: net.IpAddress) bool {
|
||||
/// One line, at info, naming what an operator needs to see in `journalctl`
|
||||
/// right after a restart: where it listens, how many upstreams it has, and
|
||||
/// whether filtering is live.
|
||||
fn logStartup(io: std.Io, manager: *manager_mod.Manager, upstream_count: usize, bound: Listeners) void {
|
||||
///
|
||||
/// Preceded by the authority (ruling 8), because every other question about a
|
||||
/// restart — why a UI edit vanished, why a file edit did not apply — starts with
|
||||
/// which of the two governs this process, and the journal is where an operator
|
||||
/// looks for it.
|
||||
fn logStartup(
|
||||
io: std.Io,
|
||||
authority: web_server.Authority,
|
||||
manager: *manager_mod.Manager,
|
||||
upstream_count: usize,
|
||||
bound: Listeners,
|
||||
) void {
|
||||
log.info("authority: {f}", .{AuthorityText{ .authority = authority }});
|
||||
|
||||
var buf: [256]u8 = undefined;
|
||||
var w: Writer = .fixed(&buf);
|
||||
appendBind(&w, "udp", bound.udp6);
|
||||
@@ -1316,6 +1642,25 @@ fn logStartup(io: std.Io, manager: *manager_mod.Manager, upstream_count: usize,
|
||||
}
|
||||
}
|
||||
|
||||
/// Which source governs this process, in the words the journal carries.
|
||||
///
|
||||
/// A formatter rather than a rendering into a buffer of this file's own: a
|
||||
/// managed path is bounded only by `Dir.max_path_bytes`, and nested bind mounts
|
||||
/// make long ones ordinary, so a fixed buffer here would silently drop exactly
|
||||
/// the half of the line an operator came for. Writing straight to the log sink's
|
||||
/// writer leaves the one documented, counted truncation in `platform/logging.zig`
|
||||
/// as the only limit.
|
||||
const AuthorityText = struct {
|
||||
authority: web_server.Authority,
|
||||
|
||||
pub fn format(self: AuthorityText, w: *Writer) Writer.Error!void {
|
||||
switch (self.authority) {
|
||||
.database => try w.writeAll("database"),
|
||||
.managed_file => |path| try w.print("file ({s})", .{path}),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Silent on overflow: a truncated startup line is not worth a failure path,
|
||||
/// and 256 bytes hold four addresses.
|
||||
fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void {
|
||||
@@ -1323,6 +1668,34 @@ fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void {
|
||||
w.print(" {s} {f}", .{ which, value }) catch {};
|
||||
}
|
||||
|
||||
test "the startup line names which source governs this process, path and all" {
|
||||
// Ruling 8. Every other question about a restart starts here, so the answer
|
||||
// is in the journal rather than derived from the unit file by whoever is
|
||||
// reading at 2am.
|
||||
const gpa = std.testing.allocator;
|
||||
|
||||
var short: Writer.Allocating = .init(gpa);
|
||||
defer short.deinit();
|
||||
try short.writer.print("{f}", .{AuthorityText{ .authority = .database }});
|
||||
try std.testing.expectEqualStrings("database", short.written());
|
||||
|
||||
var named: Writer.Allocating = .init(gpa);
|
||||
defer named.deinit();
|
||||
try named.writer.print("{f}", .{AuthorityText{ .authority = .{ .managed_file = "/etc/nxdns/config.zon" } }});
|
||||
try std.testing.expectEqualStrings("file (/etc/nxdns/config.zon)", named.written());
|
||||
|
||||
// A path past any buffer this file could reasonably have picked. Nested bind
|
||||
// mounts produce paths like this, and the path is the half of the line the
|
||||
// operator came for — dropping it to keep the line short is the wrong trade.
|
||||
const long_path = "/mnt/" ++ ("deeply-nested-mount/" ** 20) ++ "config.zon";
|
||||
try std.testing.expect(long_path.len > 256);
|
||||
|
||||
var long: Writer.Allocating = .init(gpa);
|
||||
defer long.deinit();
|
||||
try long.writer.print("{f}", .{AuthorityText{ .authority = .{ .managed_file = long_path } }});
|
||||
try std.testing.expect(std.mem.containsAtLeast(u8, long.written(), 1, long_path));
|
||||
}
|
||||
|
||||
const test_address = @import("platform/address.zig");
|
||||
|
||||
test "one maintenance pass drops the api limiter's stale buckets" {
|
||||
|
||||
+165
-106
@@ -22,6 +22,7 @@ const app = @import("app.zig");
|
||||
const config_export = @import("config/export.zig");
|
||||
const faults = @import("config/faults.zig");
|
||||
const import = @import("config/import.zig");
|
||||
const loader = @import("config/loader.zig");
|
||||
const model = @import("config/model.zig");
|
||||
const validate = @import("config/validate.zig");
|
||||
const cert_store = @import("server/cert_store.zig");
|
||||
@@ -50,19 +51,31 @@ pub const querylog_db_name = "querylog.db";
|
||||
pub const Paths = struct {
|
||||
/// PLAN §3.13.
|
||||
data_dir: []const u8 = "/var/lib/nxdns",
|
||||
config: []const u8 = "/etc/nxdns/config.zon",
|
||||
};
|
||||
|
||||
/// `config_explicit` records whether `--config` was given, because `check` has
|
||||
/// to tell "the operator named a file" from "the default path happens to
|
||||
/// exist".
|
||||
pub const CheckArgs = struct { paths: Paths = .{}, config_explicit: bool = false };
|
||||
/// Milestone-20 ruling 1: `--config` has no default path, and its presence is
|
||||
/// the whole of the authority decision. Null means the database is authority —
|
||||
/// for `run` that is today's appliance behaviour, for `check` it is the database
|
||||
/// that gets graded. A file sitting at a well-known path that no flag names
|
||||
/// changes nothing.
|
||||
pub const CheckArgs = struct { paths: Paths = .{}, config: ?[]const u8 = null };
|
||||
pub const ExportArgs = struct { paths: Paths = .{}, out: ?[]const u8 = null };
|
||||
pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, force: bool = false };
|
||||
|
||||
/// `allow_delete` is `--allow-delete`: it permits an import whose diff removes
|
||||
/// declarative rows (ruling 6).
|
||||
pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, allow_delete: bool = false };
|
||||
|
||||
/// `config` names the managed configuration file and, by being present at all,
|
||||
/// makes that file the sole declarative source of truth: it is read, validated
|
||||
/// and reconciled into the database on every start.
|
||||
///
|
||||
/// `web_dev` is milestone-8 ruling 24's `--web-dev <dir>`: serve the web
|
||||
/// interface from that directory instead of the embedded assets.
|
||||
pub const RunArgs = struct { paths: Paths = .{}, web_dev: ?[]const u8 = null };
|
||||
pub const RunArgs = struct {
|
||||
paths: Paths = .{},
|
||||
config: ?[]const u8 = null,
|
||||
web_dev: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
pub const Command = union(enum) {
|
||||
run: RunArgs,
|
||||
@@ -176,7 +189,7 @@ fn parseRunArgs(argv: []const []const u8) ParseError!RunArgs {
|
||||
if (eql(flag.name, "data-dir")) {
|
||||
args.paths.data_dir = try flagValue(flag, argv, &i);
|
||||
} else if (eql(flag.name, "config")) {
|
||||
args.paths.config = try flagValue(flag, argv, &i);
|
||||
args.config = try flagValue(flag, argv, &i);
|
||||
} else if (eql(flag.name, "web-dev")) {
|
||||
args.web_dev = try flagValue(flag, argv, &i);
|
||||
} else return error.UnknownFlag;
|
||||
@@ -192,8 +205,7 @@ fn parseCheckArgs(argv: []const []const u8) ParseError!CheckArgs {
|
||||
if (eql(flag.name, "data-dir")) {
|
||||
args.paths.data_dir = try flagValue(flag, argv, &i);
|
||||
} else if (eql(flag.name, "config")) {
|
||||
args.paths.config = try flagValue(flag, argv, &i);
|
||||
args.config_explicit = true;
|
||||
args.config = try flagValue(flag, argv, &i);
|
||||
} else return error.UnknownFlag;
|
||||
}
|
||||
return args;
|
||||
@@ -215,7 +227,7 @@ fn parseExportArgs(argv: []const []const u8) ParseError!ExportArgs {
|
||||
|
||||
fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs {
|
||||
var paths: Paths = .{};
|
||||
var force = false;
|
||||
var allow_delete = false;
|
||||
var file: ?[]const u8 = null;
|
||||
|
||||
var i: usize = 0;
|
||||
@@ -227,18 +239,18 @@ fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs {
|
||||
};
|
||||
if (eql(flag.name, "data-dir")) {
|
||||
paths.data_dir = try flagValue(flag, argv, &i);
|
||||
} else if (eql(flag.name, "force")) {
|
||||
// A boolean flag takes no value, so `--force=1` is not a spelling of
|
||||
// any flag this program has.
|
||||
} else if (eql(flag.name, "allow-delete")) {
|
||||
// A boolean flag takes no value, so `--allow-delete=1` is not a
|
||||
// spelling of any flag this program has.
|
||||
if (flag.attached != null) return error.UnknownFlag;
|
||||
force = true;
|
||||
allow_delete = true;
|
||||
} else return error.UnknownFlag;
|
||||
}
|
||||
|
||||
return .{
|
||||
.paths = paths,
|
||||
.file = file orelse return error.MissingArgument,
|
||||
.force = force,
|
||||
.allow_delete = allow_delete,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -379,14 +391,30 @@ const usage_text =
|
||||
\\
|
||||
\\options:
|
||||
\\ --data-dir DIR data directory (default /var/lib/nxdns)
|
||||
\\ --config FILE configuration file (default /etc/nxdns/config.zon)
|
||||
\\ --config FILE run: make FILE the sole source of configuration and
|
||||
\\ reconcile the database onto it at every start;
|
||||
\\ check: grade FILE instead of the database
|
||||
\\ --out FILE write the export to FILE instead of stdout
|
||||
\\ --force let import replace a database that already has content
|
||||
\\ --allow-delete let import apply a file whose diff deletes rows
|
||||
\\ --web-dev DIR run only: serve the web interface from DIR instead of
|
||||
\\ the embedded assets
|
||||
\\
|
||||
;
|
||||
|
||||
/// The one remediation line for a database that holds no usable configuration.
|
||||
/// Both routes to that state print it: `run` refusing an unconfigured database
|
||||
/// (`NoUsableUpstreams`) and `check` finding no `config.db` at all.
|
||||
///
|
||||
/// It lives here, beside the exit-code mapping, because a Zig error carries no
|
||||
/// text and `config/validate.zig` must stay blind to which source it is grading
|
||||
/// — the same file validates a database reading and a managed file.
|
||||
pub const db_source_hint =
|
||||
"load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`\n";
|
||||
|
||||
pub fn writeDbSourceHint(w: *Writer) void {
|
||||
w.writeAll(db_source_hint) catch {};
|
||||
}
|
||||
|
||||
/// Returns nothing, so a writer failure here has nowhere to go. Every caller
|
||||
/// flushes afterwards and reports that failure instead.
|
||||
pub fn usage(w: *Writer) void {
|
||||
@@ -493,7 +521,7 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
|
||||
&database,
|
||||
std.Io.Dir.cwd(),
|
||||
args.file,
|
||||
.{ .force = args.force },
|
||||
.{ .allow_delete = args.allow_delete },
|
||||
diags,
|
||||
);
|
||||
|
||||
@@ -514,9 +542,10 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
|
||||
/// That file is the only list — this function keeps none of its own, which is
|
||||
/// what stops `run`, `check` and `import` drifting apart again (D1).
|
||||
///
|
||||
/// `error.DatabaseNotEmpty` is the one exception, and it is deliberate: it
|
||||
/// reports the state of the database rather than the content of a file, so it
|
||||
/// is not a configuration fault, and `import` alone decides it is exit 2.
|
||||
/// `error.DestructiveImport` is the one exception, and it is deliberate: it
|
||||
/// reports what the diff would do to the database rather than the content of a
|
||||
/// file, so it is not a configuration fault, and `import` alone decides it is
|
||||
/// exit 2.
|
||||
///
|
||||
/// `error.OutOfMemory` is matched first, before anything else is consulted.
|
||||
/// Both recording paths — `validate` and import's per-line rendering of a ZON
|
||||
@@ -530,7 +559,7 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
|
||||
fn failureExitCode(e: anyerror, failures: usize) u8 {
|
||||
if (e == error.OutOfMemory) return exit_runtime;
|
||||
if (failures != 0) return exit_check;
|
||||
if (e == error.DatabaseNotEmpty) return exit_check;
|
||||
if (e == error.DestructiveImport) return exit_check;
|
||||
return if (faults.isConfigFault(e)) exit_check else exit_runtime;
|
||||
}
|
||||
|
||||
@@ -563,28 +592,31 @@ fn checkImpl(r: Runner, args: CheckArgs, probe: bool) !u8 {
|
||||
|
||||
// Which source was used is printed in every branch, so the answer is never
|
||||
// ambiguous about what it checked.
|
||||
if (args.config_explicit) {
|
||||
try r.out.print("checking configuration file {s}\n", .{args.paths.config});
|
||||
return checkFile(r, arena, args.paths.config, probe);
|
||||
//
|
||||
// Ruling 1: the invocation decides, and nothing else. The heuristic that
|
||||
// used to live here — probe for `config.db`, fall back to probing
|
||||
// `/etc/nxdns/config.zon`, grade whichever exists — made the answer a
|
||||
// function of what happened to be on disk, which is the ambient inference
|
||||
// that made seed-once bootstrap a source of documentation lies. A file no
|
||||
// flag names is not graded.
|
||||
if (args.config) |path| {
|
||||
try r.out.print("checking configuration file {s}\n", .{path});
|
||||
return checkFile(r, arena, path, probe);
|
||||
}
|
||||
|
||||
const config_db_path = try std.fs.path.joinZ(arena, &.{ args.paths.data_dir, config_db_name });
|
||||
if (try pathExists(r.io, config_db_path)) {
|
||||
try r.out.print("checking database {s}\n", .{config_db_path});
|
||||
return checkDatabase(r, arena, config_db_path, probe);
|
||||
if (!try pathExists(r.io, config_db_path)) {
|
||||
// The deleted heuristic's "nothing to check" branch, replaced rather
|
||||
// than dropped: an operator running `check` on a box that has never
|
||||
// been configured gets the same exit code and one line saying what to
|
||||
// do about it.
|
||||
try r.out.print("no config database at {s}\n", .{config_db_path});
|
||||
try r.out.writeAll(db_source_hint);
|
||||
return exit_check;
|
||||
}
|
||||
|
||||
if (try pathExists(r.io, args.paths.config)) {
|
||||
try r.out.print("checking configuration file {s}\n", .{args.paths.config});
|
||||
return checkFile(r, arena, args.paths.config, probe);
|
||||
}
|
||||
|
||||
try r.out.print("nothing to check: no {s} in {s} and no {s}\n", .{
|
||||
config_db_name,
|
||||
args.paths.data_dir,
|
||||
args.paths.config,
|
||||
});
|
||||
return exit_check;
|
||||
try r.out.print("checking database {s}\n", .{config_db_path});
|
||||
return checkDatabase(r, arena, config_db_path, probe);
|
||||
}
|
||||
|
||||
/// `check` reads `config.db` and writes nothing to it (F-c): no create, no
|
||||
@@ -722,52 +754,31 @@ fn pathReadable(io: std.Io, path: []const u8) std.Io.Dir.AccessError!bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Grades the file `--config` named, through `config/loader.zig` — the same
|
||||
/// read and the same classification `nxdns run --config` uses. That shared
|
||||
/// helper is what makes the scoped agreement of ruling 2 true: `check` reaches
|
||||
/// exactly the read, size and parse faults `run` would reach, and validation
|
||||
/// below is the same call on the same `Config`.
|
||||
///
|
||||
/// D4: a named file that is missing or unreadable is the same operator-fixable
|
||||
/// condition as one that fails to parse, so it is reported as a finding rather
|
||||
/// than escaping as a runtime failure.
|
||||
fn checkFile(r: Runner, arena: Allocator, path: []const u8, probe: bool) !u8 {
|
||||
const source = std.Io.Dir.cwd().readFileAllocOptions(
|
||||
r.io,
|
||||
path,
|
||||
arena,
|
||||
.limited(import.max_config_bytes),
|
||||
.of(u8),
|
||||
0,
|
||||
) catch |e| switch (e) {
|
||||
error.StreamTooLong => {
|
||||
try r.out.print("FAIL {s}: larger than {d} bytes\n", .{ path, import.max_config_bytes });
|
||||
return exit_check;
|
||||
},
|
||||
// D4: a named file that is missing or unreadable is the same
|
||||
// operator-fixable condition as one that fails to parse, so it is
|
||||
// reported as a finding rather than escaping as a runtime failure. The
|
||||
// implicit path already exits 2 when it finds nothing to check; naming
|
||||
// the file must not change the code.
|
||||
error.FileNotFound => {
|
||||
try r.out.print("FAIL {s}: no such file\n", .{path});
|
||||
return exit_check;
|
||||
},
|
||||
error.AccessDenied, error.PermissionDenied => {
|
||||
try r.out.print("FAIL {s}: not readable\n", .{path});
|
||||
return exit_check;
|
||||
},
|
||||
else => |other| return other,
|
||||
};
|
||||
var diags: validate.Diagnostics = .init(r.gpa);
|
||||
defer diags.deinit();
|
||||
|
||||
// Arena-owned and never handed to `std.zon.parse.free`; see the rule and its
|
||||
// `parse.zig:874` citation in `config/import.zig`.
|
||||
var zon_diag: std.zon.parse.Diagnostics = .{};
|
||||
const cfg = std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) {
|
||||
const cfg = loader.load(r.io, arena, std.Io.Dir.cwd(), path, &diags) catch |e| switch (e) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
// The rendering carries the line and column, which is the whole value of
|
||||
// running `check` against a file the operator just edited. It is
|
||||
// multi-line, and `check` promises one line per problem, so it goes
|
||||
// through the same `Diagnostics` channel `nxdns import` uses rather than
|
||||
// into one `FAIL` record with newlines inside it.
|
||||
error.ParseZon => {
|
||||
var diags: validate.Diagnostics = .init(r.gpa);
|
||||
defer diags.deinit();
|
||||
try import.reportParseFailure(&diags, &zon_diag);
|
||||
error.ManagedConfigUnreadable, error.ConfigTooLarge, error.ParseZon => {
|
||||
// One line per problem, the promise the rest of `check` keeps: a
|
||||
// multi-line ZON rendering is several problems, not one `FAIL`
|
||||
// record with newlines inside it.
|
||||
try diags.writeAll(r.out);
|
||||
return exit_check;
|
||||
},
|
||||
// A box fault — fd exhaustion, an I/O error — is not a verdict on the
|
||||
// configuration and keeps its own name at exit 1.
|
||||
else => |other| return other,
|
||||
};
|
||||
|
||||
return checkConfig(r, cfg, probe);
|
||||
@@ -1013,17 +1024,22 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "parseArgs accepts run with no flags" {
|
||||
test "run without --config selects database authority" {
|
||||
// Ruling 1: authority is the invocation. No default path, so nothing on
|
||||
// disk can make a bare `run` read a file.
|
||||
const command = try parseArgs(&.{"run"});
|
||||
try testing.expectEqualStrings("/var/lib/nxdns", command.run.paths.data_dir);
|
||||
try testing.expectEqualStrings("/etc/nxdns/config.zon", command.run.paths.config);
|
||||
try testing.expectEqual(@as(?[]const u8, null), command.run.config);
|
||||
try testing.expectEqual(@as(?[]const u8, null), command.run.web_dev);
|
||||
}
|
||||
|
||||
test "parseArgs accepts run with --data-dir and --config" {
|
||||
test "run --config selects file authority and the path lands in the run args" {
|
||||
const attached = try parseArgs(&.{ "run", "--config=/etc/nxdns/config.zon" });
|
||||
try testing.expectEqualStrings("/etc/nxdns/config.zon", attached.run.config.?);
|
||||
|
||||
const command = try parseArgs(&.{ "run", "--data-dir", "/srv/nx", "--config", "/tmp/c.zon" });
|
||||
try testing.expectEqualStrings("/srv/nx", command.run.paths.data_dir);
|
||||
try testing.expectEqualStrings("/tmp/c.zon", command.run.paths.config);
|
||||
try testing.expectEqualStrings("/tmp/c.zon", command.run.config.?);
|
||||
}
|
||||
|
||||
test "parseArgs accepts run with --web-dev in both spellings" {
|
||||
@@ -1049,13 +1065,12 @@ test "parseArgs accepts --data-dir with and without an equals sign" {
|
||||
try testing.expectEqualStrings("/srv/nx", separate.check.paths.data_dir);
|
||||
}
|
||||
|
||||
test "parseArgs records whether check was given an explicit --config" {
|
||||
test "bare check grades the database and check --config grades the file" {
|
||||
const implicit = try parseArgs(&.{"check"});
|
||||
try testing.expect(!implicit.check.config_explicit);
|
||||
try testing.expectEqual(@as(?[]const u8, null), implicit.check.config);
|
||||
|
||||
const explicit = try parseArgs(&.{ "check", "--config=/tmp/c.zon" });
|
||||
try testing.expect(explicit.check.config_explicit);
|
||||
try testing.expectEqualStrings("/tmp/c.zon", explicit.check.paths.config);
|
||||
try testing.expectEqualStrings("/tmp/c.zon", explicit.check.config.?);
|
||||
}
|
||||
|
||||
test "parseArgs accepts export with --out" {
|
||||
@@ -1066,17 +1081,21 @@ test "parseArgs accepts export with --out" {
|
||||
try testing.expectEqual(@as(?[]const u8, null), bare.export_.out);
|
||||
}
|
||||
|
||||
test "parseArgs accepts import with a file, --force and --data-dir" {
|
||||
const command = try parseArgs(&.{ "import", "c.zon", "--force", "--data-dir=/srv/nx" });
|
||||
test "parseArgs accepts import with a file, --allow-delete and --data-dir" {
|
||||
const command = try parseArgs(&.{ "import", "c.zon", "--allow-delete", "--data-dir=/srv/nx" });
|
||||
try testing.expectEqualStrings("c.zon", command.import_.file);
|
||||
try testing.expect(command.import_.force);
|
||||
try testing.expect(command.import_.allow_delete);
|
||||
try testing.expectEqualStrings("/srv/nx", command.import_.paths.data_dir);
|
||||
}
|
||||
|
||||
test "parseArgs accepts import with the file after the flags" {
|
||||
const command = try parseArgs(&.{ "import", "--data-dir", "/srv/nx", "c.zon" });
|
||||
try testing.expectEqualStrings("c.zon", command.import_.file);
|
||||
try testing.expect(!command.import_.force);
|
||||
try testing.expect(!command.import_.allow_delete);
|
||||
}
|
||||
|
||||
test "the renamed import flag replaces --force rather than joining it" {
|
||||
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--force" }));
|
||||
}
|
||||
|
||||
test "parseArgs accepts version" {
|
||||
@@ -1091,7 +1110,7 @@ test "parseArgs accepts help, --help and -h" {
|
||||
|
||||
test "parseArgs rejects import without a file" {
|
||||
try testing.expectError(error.MissingArgument, parseArgs(&.{"import"}));
|
||||
try testing.expectError(error.MissingArgument, parseArgs(&.{ "import", "--force" }));
|
||||
try testing.expectError(error.MissingArgument, parseArgs(&.{ "import", "--allow-delete" }));
|
||||
}
|
||||
|
||||
test "parseArgs rejects --out without a value" {
|
||||
@@ -1101,7 +1120,7 @@ test "parseArgs rejects --out without a value" {
|
||||
|
||||
test "parseArgs rejects an unknown flag" {
|
||||
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "check", "--nope" }));
|
||||
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--force=1" }));
|
||||
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--allow-delete=1" }));
|
||||
}
|
||||
|
||||
test "parseArgs rejects an unknown command" {
|
||||
@@ -1134,6 +1153,17 @@ test "usage_text lists every command in command_names" {
|
||||
}
|
||||
}
|
||||
|
||||
test "usage_text names the flags this milestone renamed and describes --config" {
|
||||
// The flag an operator reaches for is the one the help text names. `--force`
|
||||
// is gone rather than aliased (greenfield rules), and `--config` no longer
|
||||
// advertises a default path, because there is none: its presence is the
|
||||
// whole authority decision.
|
||||
try testing.expect(std.mem.containsAtLeast(u8, usage_text, 1, " --allow-delete "));
|
||||
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, usage_text, "--force"));
|
||||
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, usage_text, "default /etc/nxdns/config.zon"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, usage_text, 1, "sole source of configuration"));
|
||||
}
|
||||
|
||||
test "usage writes non-empty text" {
|
||||
var out: Writer.Allocating = .init(testing.allocator);
|
||||
defer out.deinit();
|
||||
@@ -1246,7 +1276,7 @@ test "runUsageError names the fault and prints the usage text" {
|
||||
}
|
||||
|
||||
test "failureExitCode separates a fixable configuration from a runtime failure" {
|
||||
try testing.expectEqual(exit_check, failureExitCode(error.DatabaseNotEmpty, 0));
|
||||
try testing.expectEqual(exit_check, failureExitCode(error.DestructiveImport, 0));
|
||||
try testing.expectEqual(exit_check, failureExitCode(error.ParseZon, 0));
|
||||
try testing.expectEqual(exit_check, failureExitCode(error.NoUpstreams, 1));
|
||||
try testing.expectEqual(exit_runtime, failureExitCode(error.IoErr, 0));
|
||||
@@ -1302,9 +1332,13 @@ test "failureExitCode keeps no list of its own and classifies through config/fau
|
||||
}
|
||||
|
||||
// The one config-shaped exit 2 `cli` still decides for itself: it reports
|
||||
// the state of the database, not the content of a file.
|
||||
try testing.expect(!faults.isConfigFault(error.DatabaseNotEmpty));
|
||||
try testing.expectEqual(exit_check, failureExitCode(error.DatabaseNotEmpty, 0));
|
||||
// what the diff would do to the database, not the content of a file.
|
||||
try testing.expect(!faults.isConfigFault(error.DestructiveImport));
|
||||
try testing.expectEqual(exit_check, failureExitCode(error.DestructiveImport, 0));
|
||||
|
||||
// The managed file goes the other way: `config/loader.zig` converts the
|
||||
// path class, so the classification — not this function — carries it.
|
||||
try testing.expectEqual(exit_check, failureExitCode(error.ManagedConfigUnreadable, 0));
|
||||
}
|
||||
|
||||
const fixtures = @import("test_fixtures");
|
||||
@@ -1457,10 +1491,7 @@ test "check --config naming a missing file is a reported failure at exit 2" {
|
||||
defer captured.deinit();
|
||||
const r = captured.runner();
|
||||
|
||||
const code = runCheck(r, .{
|
||||
.paths = .{ .config = env.missing_path },
|
||||
.config_explicit = true,
|
||||
}, false);
|
||||
const code = runCheck(r, .{ .config = env.missing_path }, false);
|
||||
|
||||
try testing.expectEqual(exit_check, code);
|
||||
const text = captured.out.written();
|
||||
@@ -1469,6 +1500,37 @@ test "check --config naming a missing file is a reported failure at exit 2" {
|
||||
try testing.expectEqualStrings("", captured.err.written());
|
||||
}
|
||||
|
||||
test "bare check with no config database exits 2 and says how to make one" {
|
||||
// The deleted heuristic's "nothing to check" branch, replaced. The valid
|
||||
// file sitting in the same directory is the other half of ruling 1: bare
|
||||
// `check` grades the database, and a file no flag named is not consulted —
|
||||
// if it were, this run would print "OK: no problems found" instead.
|
||||
var env: CheckEnv = undefined;
|
||||
try env.init();
|
||||
defer env.deinit();
|
||||
|
||||
var captured: Captured = .init(testing.allocator);
|
||||
defer captured.deinit();
|
||||
const r = captured.runner();
|
||||
|
||||
try env.tmp.dir.writeFile(r.io, .{ .sub_path = "config.zon", .data =
|
||||
\\.{
|
||||
\\ .groups = .{ .{ .name = "default" } },
|
||||
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
|
||||
\\}
|
||||
});
|
||||
|
||||
const code = runCheck(r, .{ .paths = .{ .data_dir = env.data_dir } }, false);
|
||||
|
||||
try testing.expectEqual(exit_check, code);
|
||||
const text = captured.out.written();
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "no config database at "));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, config_db_name));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, db_source_hint));
|
||||
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, text, "OK"));
|
||||
try testing.expectEqualStrings("", captured.err.written());
|
||||
}
|
||||
|
||||
test "check renders a multi-line ZON failure as one FAIL line per message" {
|
||||
// The rendering used to go inline into a single `FAIL` record, which put
|
||||
// newlines mid-line and broke the one-line-per-problem promise the rest of
|
||||
@@ -1486,10 +1548,7 @@ test "check renders a multi-line ZON failure as one FAIL line per message" {
|
||||
var path_buf: [160]u8 = undefined;
|
||||
const config_path = try env.path(&path_buf, "config.zon");
|
||||
|
||||
const code = runCheck(r, .{
|
||||
.paths = .{ .config = config_path },
|
||||
.config_explicit = true,
|
||||
}, false);
|
||||
const code = runCheck(r, .{ .config = config_path }, false);
|
||||
try testing.expectEqual(exit_check, code);
|
||||
|
||||
const text = captured.out.written();
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
//! First-start seeding (PLAN §3.5).
|
||||
//!
|
||||
//! A policy wrapper over `import.importFile`, and nothing more. There is exactly
|
||||
//! one code path from a config file into the database, so bootstrap and
|
||||
//! `nxdns import` cannot drift apart.
|
||||
//!
|
||||
//! The policy is three lines long:
|
||||
//!
|
||||
//! - no file → normal steady state, keep the database as it is;
|
||||
//! - database already configured → the file is ignored, as PLAN §3.5 requires.
|
||||
//! Configured means an operator put something there. A database that has only
|
||||
//! answered queries is not configured, however many client rows the DNS path
|
||||
//! materialised into it, and `import.isEmpty` is where that line is drawn;
|
||||
//! - otherwise → import it, and a file that is unreadable, unparseable or
|
||||
//! invalid is an error. The operator wrote that file and meant it; starting
|
||||
//! with silent defaults instead is the exact failure mode PLAN §1.3 exists to
|
||||
//! prevent.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const db = @import("../storage/db.zig");
|
||||
const import = @import("import.zig");
|
||||
const validate = @import("validate.zig");
|
||||
|
||||
const log = std.log.scoped(.config_bootstrap);
|
||||
|
||||
pub const Outcome = enum { seeded, db_already_configured, no_config_file };
|
||||
|
||||
pub const Error = import.Error || std.Io.Dir.AccessError;
|
||||
|
||||
/// Called by `nxdns run` before serving.
|
||||
pub fn bootstrap(
|
||||
io: std.Io,
|
||||
gpa: Allocator,
|
||||
database: *db.Db,
|
||||
dir: std.Io.Dir,
|
||||
config_path: []const u8,
|
||||
diags: *validate.Diagnostics,
|
||||
) Error!Outcome {
|
||||
dir.access(io, config_path, .{}) catch |e| switch (e) {
|
||||
error.FileNotFound => {
|
||||
log.info("no configuration file at '{s}'; using the database as it is", .{config_path});
|
||||
return .no_config_file;
|
||||
},
|
||||
else => |other| return other,
|
||||
};
|
||||
|
||||
// Deliberately before the read: on every start after the first, the file is
|
||||
// not even opened.
|
||||
if (!try import.isEmpty(database)) {
|
||||
log.info("configuration file ignored; the database is already configured", .{});
|
||||
return .db_already_configured;
|
||||
}
|
||||
|
||||
try import.importFile(io, gpa, database, dir, config_path, .{ .force = false }, diags);
|
||||
log.info("seeded the database from '{s}'", .{config_path});
|
||||
return .seeded;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// All three outcomes are exercised end to end in
|
||||
// `src/storage/storage_integration_test.zig` (S7) against a real data directory.
|
||||
// What the two cases below add is the one distinction that decides which outcome
|
||||
// an operator gets, and it is too important to leave behind a `-Dintegration`
|
||||
// flag: whether the database has been *configured*, not whether it has been
|
||||
// *used*.
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
const clients_repo = @import("../storage/repositories/clients_repo.zig");
|
||||
const migrations = @import("../storage/migrations.zig");
|
||||
|
||||
const seed_source =
|
||||
\\.{
|
||||
\\ .groups = .{ .{ .name = "default" } },
|
||||
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
|
||||
\\}
|
||||
;
|
||||
|
||||
/// Unparseable on purpose: a call that succeeds proves the file was never read.
|
||||
const broken_source = ".{ .groups = ";
|
||||
|
||||
fn openMigrated() !db.Db {
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
errdefer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
_ = try migrations.migrate(&database);
|
||||
return database;
|
||||
}
|
||||
|
||||
test "a server that has answered queries still seeds from its configuration file" {
|
||||
const io = testing.io;
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = seed_source });
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
// The unattended first boot: the server came up on defaults, answered
|
||||
// traffic, and the operator dropped a config file in afterwards.
|
||||
try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);
|
||||
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
|
||||
const outcome = try bootstrap(io, testing.allocator, &database, tmp.dir, "config.zon", &diags);
|
||||
try testing.expectEqual(Outcome.seeded, outcome);
|
||||
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
|
||||
// Seeding did not cost the operator the device list they had been watching.
|
||||
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
|
||||
}
|
||||
|
||||
test "a client the operator has customised keeps the configuration file out" {
|
||||
const io = testing.io;
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = broken_source });
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);
|
||||
const id = try database.queryInt("SELECT id FROM clients WHERE ip = '192.168.1.5'");
|
||||
try clients_repo.updateClient(&database, id, .{ .name = "tv", .group_id = 1 });
|
||||
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
|
||||
const outcome = try bootstrap(io, testing.allocator, &database, tmp.dir, "config.zon", &diags);
|
||||
try testing.expectEqual(Outcome.db_already_configured, outcome);
|
||||
try testing.expectEqual(@as(usize, 0), diags.problems.items.len);
|
||||
// The name and the flag the operator set are still theirs.
|
||||
try testing.expectEqual(@as(i64, 1), try database.queryInt(
|
||||
"SELECT count(*) FROM clients WHERE name = 'tv' AND hand_edited = 1",
|
||||
));
|
||||
}
|
||||
+57
-10
@@ -34,7 +34,7 @@ pub const Error = ReadError || Writer.Error ||
|
||||
|
||||
const header =
|
||||
\\// nxdns configuration
|
||||
\\// generated by `nxdns export` — the database is the source of truth
|
||||
\\// generated by `nxdns export` from the running configuration
|
||||
\\
|
||||
;
|
||||
|
||||
@@ -64,11 +64,13 @@ pub fn readConfig(database: *db.Db, arena: Allocator) ReadError!model.Config {
|
||||
cfg.local_records = (try local_repo.listLocalRecords(database, arena)).items;
|
||||
cfg.forward_zones = (try local_repo.listForwardZones(database, arena)).items;
|
||||
|
||||
// `web.password` is operator input and is never stored; the exported file
|
||||
// always carries an empty one. This is exactly what makes the round trip
|
||||
// stable: re-importing takes the "password is empty" branch and stores the
|
||||
// same hash.
|
||||
cfg.web.password = "";
|
||||
// `web.password` is operator input and is never stored, so the exported
|
||||
// file always states it as absent. Absent rather than `""`: a present empty
|
||||
// password is refused by `validate` (ruling 4), so exporting one would make
|
||||
// every export fail its own rules. It is also what makes the round trip
|
||||
// stable — re-applying the file takes the "password_hash written verbatim"
|
||||
// branch and stores the same hash.
|
||||
cfg.web.password = null;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
@@ -250,7 +252,7 @@ test "readConfig, writeConfig, import and readConfig again produce an equal conf
|
||||
|
||||
try testing.expectEqual(a.dns.port, b.dns.port);
|
||||
try testing.expectEqual(a.logging.level, b.logging.level);
|
||||
try testing.expectEqualStrings(a.web.password_hash, b.web.password_hash);
|
||||
try testing.expectEqualStrings(a.web.password_hash.?, b.web.password_hash.?);
|
||||
try testing.expectEqual(a.groups.len, b.groups.len);
|
||||
try testing.expectEqual(a.upstreams.len, b.upstreams.len);
|
||||
for (a.upstreams, b.upstreams) |left, right| {
|
||||
@@ -303,12 +305,57 @@ test "an exported password_hash survives a re-import unchanged" {
|
||||
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
|
||||
.web = .{ .password = "correct horse battery staple" },
|
||||
};
|
||||
try import.applyToDb(io, gpa, &database, cfg, 42, .{});
|
||||
var diags: validate.Diagnostics = .init(gpa);
|
||||
defer diags.deinit();
|
||||
try import.apply(io, gpa, &database, cfg, 42, .{}, &diags);
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena_state.deinit();
|
||||
const exported = try readConfig(&database, arena_state.allocator());
|
||||
|
||||
try testing.expectEqualStrings("", exported.web.password);
|
||||
try testing.expect(std.mem.startsWith(u8, exported.web.password_hash, "$argon2id$"));
|
||||
try testing.expectEqual(@as(?[]const u8, null), exported.web.password);
|
||||
try testing.expect(std.mem.startsWith(u8, exported.web.password_hash.?, "$argon2id$"));
|
||||
}
|
||||
|
||||
test "the exported password form is the one validate accepts" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
const gpa = testing.allocator;
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
var apply_diags: validate.Diagnostics = .init(gpa);
|
||||
defer apply_diags.deinit();
|
||||
try import.apply(io, gpa, &database, .{
|
||||
.groups = &.{.{ .name = "default" }},
|
||||
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
|
||||
.web = .{ .password = "correct horse battery staple" },
|
||||
}, 42, .{}, &apply_diags);
|
||||
|
||||
var out: Writer.Allocating = .init(gpa);
|
||||
defer out.deinit();
|
||||
try writeToWriter(gpa, &database, &out.writer);
|
||||
|
||||
// The literal form matters: an export carrying `password = ""` beside a
|
||||
// stored hash would trip `EmptyWebPassword` on the way back in, so export
|
||||
// would produce a file its own validator refuses.
|
||||
try testing.expect(std.mem.indexOf(u8, out.written(), ".password = null,") != null);
|
||||
|
||||
const source = try gpa.dupeZ(u8, out.written());
|
||||
defer gpa.free(source);
|
||||
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena_state.deinit();
|
||||
const reparsed = try std.zon.parse.fromSliceAlloc(
|
||||
model.Config,
|
||||
arena_state.allocator(),
|
||||
source,
|
||||
null,
|
||||
.{},
|
||||
);
|
||||
var diags: validate.Diagnostics = .init(gpa);
|
||||
defer diags.deinit();
|
||||
try validate.validate(reparsed, &diags);
|
||||
try testing.expectEqual(@as(?[]const u8, null), reparsed.web.password);
|
||||
try testing.expect(std.mem.startsWith(u8, reparsed.web.password_hash.?, "$argon2id$"));
|
||||
}
|
||||
|
||||
+27
-10
@@ -13,17 +13,26 @@ const validate = @import("validate.zig");
|
||||
|
||||
/// `ValidateError` enters as a whole set rather than variant by variant, so a
|
||||
/// variant added to the validator cannot silently fall through to exit 1. The
|
||||
/// four extras are the configuration faults raised outside the validator: the
|
||||
/// ZON reader (`ParseZon`), the seed-file size limit (`ConfigTooLarge`), the
|
||||
/// composition root's upstream build (`NoUsableUpstreams`) and its certificate
|
||||
/// load (`BadCertificate`).
|
||||
/// five extras are the configuration faults raised outside the validator: the
|
||||
/// ZON reader (`ParseZon`), the file size limit (`ConfigTooLarge`), the managed
|
||||
/// file the operator named and this process cannot open
|
||||
/// (`ManagedConfigUnreadable`, milestone-20 ruling 2), the composition root's
|
||||
/// upstream build (`NoUsableUpstreams`) and its certificate load
|
||||
/// (`BadCertificate`).
|
||||
///
|
||||
/// Not here on purpose: `error.DatabaseNotEmpty`, which reports the state of
|
||||
/// the database rather than the content of a file, and is the one config-shaped
|
||||
/// exit 2 `cli` decides for itself.
|
||||
/// `ManagedConfigUnreadable` is the only place a missing or unreadable path is a
|
||||
/// configuration fault, and it is deliberately not `FileNotFound` itself: the
|
||||
/// operator named that path on the command line, so it is theirs to fix, while a
|
||||
/// missing file anywhere else stays a runtime failure. `config/loader.zig` owns
|
||||
/// the conversion and the closed set of open errors that qualify.
|
||||
///
|
||||
/// Not here on purpose: `error.DestructiveImport`, which reports what an import
|
||||
/// would do to the database rather than the content of a file, and is the one
|
||||
/// config-shaped exit 2 `cli` decides for itself.
|
||||
const ConfigFault = validate.ValidateError || error{
|
||||
ParseZon,
|
||||
ConfigTooLarge,
|
||||
ManagedConfigUnreadable,
|
||||
NoUsableUpstreams,
|
||||
BadCertificate,
|
||||
};
|
||||
@@ -94,6 +103,14 @@ test "the faults raised outside the validator are configuration faults" {
|
||||
try testing.expect(isConfigFault(error.BadCertificate));
|
||||
}
|
||||
|
||||
test "a managed file the operator named and this process cannot open is exit 2" {
|
||||
// Ruling 2. The general rule below still holds — a bare `FileNotFound` is a
|
||||
// runtime failure — and this is the one converted form, produced only by
|
||||
// `config/loader.zig` for a path `--config` named.
|
||||
try testing.expect(isConfigFault(error.ManagedConfigUnreadable));
|
||||
try testing.expect(!isConfigFault(error.FileNotFound));
|
||||
}
|
||||
|
||||
test "the seed-file errors that used to exit 1 from run are configuration faults" {
|
||||
// D1 verbatim: these three reached `run` from a rejected seed file and were
|
||||
// classified as runtime failures.
|
||||
@@ -107,9 +124,9 @@ test "a runtime failure is not a configuration fault" {
|
||||
try testing.expect(!isConfigFault(error.AccessDenied));
|
||||
try testing.expect(!isConfigFault(error.FileNotFound));
|
||||
try testing.expect(!isConfigFault(error.AddressInUse));
|
||||
// A state conflict, not a bad file: `import` refuses to overwrite a
|
||||
// configured database and decides that exit code itself.
|
||||
try testing.expect(!isConfigFault(error.DatabaseNotEmpty));
|
||||
// A verdict on the diff, not on the file: `import` refuses a run that would
|
||||
// delete rows and decides that exit code itself.
|
||||
try testing.expect(!isConfigFault(error.DestructiveImport));
|
||||
// Only ever a warning, so it never reaches an exit code by this route.
|
||||
try testing.expect(!isConfigFault(error.SourceInNoGroup));
|
||||
}
|
||||
|
||||
+268
-675
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,323 @@
|
||||
//! The one way a configuration file becomes a `model.Config`.
|
||||
//!
|
||||
//! `nxdns run --config <file>` and `nxdns check --config <file>` must grade the
|
||||
//! same file the same way, so the read, the error classification and the parse
|
||||
//! live here rather than once per subcommand. `config/faults.zig` exists for the
|
||||
//! same reason one layer up: two copies of a rule are two rules.
|
||||
//!
|
||||
//! **The classification.** `faults.isConfigFault` deliberately excludes
|
||||
//! `FileNotFound` and `AccessDenied` in general — a missing file is usually a
|
||||
//! broken box, not a wrong configuration. The managed file is the one place
|
||||
//! where the opposite holds: the operator named that path, so a path that does
|
||||
//! not resolve is a configuration fault (exit 2, `nxdns check` is the next
|
||||
//! step). Only the path class converts:
|
||||
//!
|
||||
//! `FileNotFound`, `AccessDenied`, `PermissionDenied`, `NotDir`, `IsDir`,
|
||||
//! `SymLinkLoop`, `NameTooLong`, `BadPathName` → `ManagedConfigUnreadable`
|
||||
//!
|
||||
//! Everything else `readFileAllocOptions` can return — `SystemResources`, the
|
||||
//! two fd-quota errors, I/O failures, `OutOfMemory` — propagates unmapped and
|
||||
//! exits 1. Those are box faults a retry can clear, and the shipped unit carries
|
||||
//! `RestartPreventExitStatus=2 64`: mapping a transient failure to exit 2 would
|
||||
//! stop the service permanently on a fault that would have cleared itself.
|
||||
//!
|
||||
//! The mapping is a named error set switched exhaustively with
|
||||
//! `else => |other| return other`, so an error a Zig upgrade adds to
|
||||
//! `ReadFileAllocError` defaults to exit 1 rather than silently to exit 2.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const model = @import("model.zig");
|
||||
const validate = @import("validate.zig");
|
||||
|
||||
/// The ceiling on a configuration file. A file above it is a configuration
|
||||
/// fault, not a resource failure: nothing an operator writes by hand comes near
|
||||
/// 4 MiB, so this is a typo or a wrong path rather than a real config.
|
||||
pub const max_config_bytes = 4 * 1024 * 1024;
|
||||
|
||||
/// Every error `readFileAllocOptions` can hand back, plus the parse.
|
||||
pub const ReadError = std.Io.Dir.ReadFileAllocError;
|
||||
|
||||
/// The open failures that mean the operator's path is wrong rather than the box
|
||||
/// being broken. Spelled out as a set rather than as switch prongs so that the
|
||||
/// list is one thing a reader can find and a test can enumerate.
|
||||
pub const PathFault = error{
|
||||
FileNotFound,
|
||||
AccessDenied,
|
||||
PermissionDenied,
|
||||
NotDir,
|
||||
IsDir,
|
||||
SymLinkLoop,
|
||||
NameTooLong,
|
||||
BadPathName,
|
||||
};
|
||||
|
||||
pub const Error = ReadError || error{ ManagedConfigUnreadable, ConfigTooLarge, ParseZon };
|
||||
|
||||
/// The classification itself, pure and testable on its own: a path-class
|
||||
/// failure becomes `ManagedConfigUnreadable`, the size limit becomes
|
||||
/// `ConfigTooLarge`, and every other member travels unchanged.
|
||||
pub fn mapReadError(e: ReadError) Error {
|
||||
return switch (e) {
|
||||
error.StreamTooLong => error.ConfigTooLarge,
|
||||
error.FileNotFound,
|
||||
error.AccessDenied,
|
||||
error.PermissionDenied,
|
||||
error.NotDir,
|
||||
error.IsDir,
|
||||
error.SymLinkLoop,
|
||||
error.NameTooLong,
|
||||
error.BadPathName,
|
||||
=> error.ManagedConfigUnreadable,
|
||||
else => |other| other,
|
||||
};
|
||||
}
|
||||
|
||||
/// The file, NUL-terminated because `std.zon.parse` needs a sentinel and
|
||||
/// `readFileAlloc` cannot supply one. Errors travel exactly as the filesystem
|
||||
/// returned them: `nxdns import` reads an operator-supplied argument, not a
|
||||
/// managed file, and its exit codes are its own.
|
||||
pub fn readSource(
|
||||
io: std.Io,
|
||||
gpa: Allocator,
|
||||
dir: std.Io.Dir,
|
||||
path: []const u8,
|
||||
) ReadError![:0]u8 {
|
||||
return dir.readFileAllocOptions(io, path, gpa, .limited(max_config_bytes), .of(u8), 0);
|
||||
}
|
||||
|
||||
/// `readSource` under the managed-file classification, with the reason recorded
|
||||
/// as a diagnostic. A Zig error carries no text, so the path an operator has to
|
||||
/// go and fix reaches them through `Diagnostics` — the same channel every other
|
||||
/// configuration problem travels down, and the reason `check` and `run` print
|
||||
/// these in one shape.
|
||||
pub fn readManaged(
|
||||
io: std.Io,
|
||||
gpa: Allocator,
|
||||
dir: std.Io.Dir,
|
||||
path: []const u8,
|
||||
diags: *validate.Diagnostics,
|
||||
) Error![:0]u8 {
|
||||
return readSource(io, gpa, dir, path) catch |e| {
|
||||
switch (e) {
|
||||
error.StreamTooLong => try diags.add(
|
||||
error.ConfigTooLarge,
|
||||
"{s}",
|
||||
.{path},
|
||||
"larger than {d} bytes",
|
||||
.{max_config_bytes},
|
||||
),
|
||||
error.FileNotFound => try diags.add(
|
||||
error.ManagedConfigUnreadable,
|
||||
"{s}",
|
||||
.{path},
|
||||
"no such file",
|
||||
.{},
|
||||
),
|
||||
error.AccessDenied, error.PermissionDenied => try diags.add(
|
||||
error.ManagedConfigUnreadable,
|
||||
"{s}",
|
||||
.{path},
|
||||
"not readable",
|
||||
.{},
|
||||
),
|
||||
error.IsDir => try diags.add(
|
||||
error.ManagedConfigUnreadable,
|
||||
"{s}",
|
||||
.{path},
|
||||
"is a directory, not a configuration file",
|
||||
.{},
|
||||
),
|
||||
error.NotDir, error.SymLinkLoop, error.NameTooLong, error.BadPathName => try diags.add(
|
||||
error.ManagedConfigUnreadable,
|
||||
"{s}",
|
||||
.{path},
|
||||
"cannot be opened ({s})",
|
||||
.{@errorName(e)},
|
||||
),
|
||||
// A box fault. It exits 1 with its own name and records nothing: a
|
||||
// diagnostic would file it under "the configuration is wrong".
|
||||
else => {},
|
||||
}
|
||||
return mapReadError(e);
|
||||
};
|
||||
}
|
||||
|
||||
/// The line and column of a ZON syntax error are the only thing the operator can
|
||||
/// act on, so they travel the same channel as every other config problem: the
|
||||
/// caller's `Diagnostics`, which `run`, `check` and `import` all render. The
|
||||
/// global log is not that channel — an operator reading command output would see
|
||||
/// a bare `ParseZon` and nothing else.
|
||||
///
|
||||
/// `std.zon.parse.Diagnostics` renders one "line:column: error: text" line per
|
||||
/// problem, plus a "note:" line each, so each rendered line becomes one
|
||||
/// `Problem` and the list keeps the parser's order. Rendering them inline would
|
||||
/// put newlines inside a single `FAIL` record.
|
||||
pub fn reportParseFailure(
|
||||
diags: *validate.Diagnostics,
|
||||
zon_diag: *const std.zon.parse.Diagnostics,
|
||||
) error{OutOfMemory}!void {
|
||||
const rendered = try std.fmt.allocPrint(diags.gpa, "{f}", .{zon_diag});
|
||||
defer diags.gpa.free(rendered);
|
||||
|
||||
var lines = std.mem.splitScalar(u8, rendered, '\n');
|
||||
while (lines.next()) |line| {
|
||||
if (line.len == 0) continue;
|
||||
try diags.add(error.ParseZon, "config", .{}, "{s}", .{line});
|
||||
}
|
||||
}
|
||||
|
||||
/// The parse, with its failure rendered. The result is arena-owned and
|
||||
/// `std.zon.parse.free` is NEVER called on it: `Parser.parseStruct` fills an
|
||||
/// absent field by copying the struct's default straight through
|
||||
/// (parse.zig:874), so a defaulted `[]const u8` — and this model has many
|
||||
/// non-empty string defaults — points into the binary's read-only data.
|
||||
/// `parse.free` keeps no record of which fields were parsed and which were
|
||||
/// defaulted, so it would `@memset` and free rodata. Freeing the arena is the
|
||||
/// only correct release.
|
||||
pub fn parse(
|
||||
arena: Allocator,
|
||||
source: [:0]const u8,
|
||||
diags: *validate.Diagnostics,
|
||||
) error{ ParseZon, OutOfMemory }!model.Config {
|
||||
var zon_diag: std.zon.parse.Diagnostics = .{};
|
||||
return std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) {
|
||||
error.OutOfMemory => error.OutOfMemory,
|
||||
error.ParseZon => {
|
||||
try reportParseFailure(diags, &zon_diag);
|
||||
return error.ParseZon;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// Read and parse the managed file, everything a caller needs before
|
||||
/// `validate.validate`. Validation is deliberately left to the caller: `check`
|
||||
/// runs it beside its certificate and upstream probes, `run` runs it alone, and
|
||||
/// both call the same validator on the same `Config`, which is what makes the
|
||||
/// two agree.
|
||||
///
|
||||
/// `arena` owns both the source text and the returned configuration.
|
||||
pub fn load(
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
dir: std.Io.Dir,
|
||||
path: []const u8,
|
||||
diags: *validate.Diagnostics,
|
||||
) Error!model.Config {
|
||||
const source = try readManaged(io, arena, dir, path, diags);
|
||||
return parse(arena, source, diags);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "every path-class open failure is a managed-config fault" {
|
||||
inline for (@typeInfo(PathFault).error_set.?) |member| {
|
||||
const e = @field(ReadError, member.name);
|
||||
try testing.expectEqual(error.ManagedConfigUnreadable, mapReadError(e));
|
||||
}
|
||||
}
|
||||
|
||||
test "a box fault outside the path class propagates unmapped" {
|
||||
// Each of these exits 1: a retry can clear them, and the shipped unit's
|
||||
// `RestartPreventExitStatus=2 64` would make exit 2 permanent.
|
||||
try testing.expectEqual(error.SystemResources, mapReadError(error.SystemResources));
|
||||
try testing.expectEqual(error.ProcessFdQuotaExceeded, mapReadError(error.ProcessFdQuotaExceeded));
|
||||
try testing.expectEqual(error.SystemFdQuotaExceeded, mapReadError(error.SystemFdQuotaExceeded));
|
||||
try testing.expectEqual(error.OutOfMemory, mapReadError(error.OutOfMemory));
|
||||
try testing.expectEqual(error.InputOutput, mapReadError(error.InputOutput));
|
||||
}
|
||||
|
||||
test "the size limit is its own fault, not an unreadable path" {
|
||||
try testing.expectEqual(error.ConfigTooLarge, mapReadError(error.StreamTooLong));
|
||||
}
|
||||
|
||||
test "the path class is exactly the eight members the ruling names" {
|
||||
// A member added to `PathFault` without a decision recorded in the spec
|
||||
// fails here rather than quietly moving an exit code from 1 to 2.
|
||||
const expected = [_][]const u8{
|
||||
"FileNotFound", "AccessDenied", "PermissionDenied", "NotDir",
|
||||
"IsDir", "SymLinkLoop", "NameTooLong", "BadPathName",
|
||||
};
|
||||
const members = @typeInfo(PathFault).error_set.?;
|
||||
try testing.expectEqual(expected.len, members.len);
|
||||
inline for (members) |member| {
|
||||
var found = false;
|
||||
for (expected) |name| {
|
||||
if (std.mem.eql(u8, name, member.name)) found = true;
|
||||
}
|
||||
try testing.expect(found);
|
||||
}
|
||||
}
|
||||
|
||||
test "a missing managed file records the path and the reason" {
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
try testing.expectError(
|
||||
error.ManagedConfigUnreadable,
|
||||
load(testing.io, arena_state.allocator(), tmp.dir, "nope.zon", &diags),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 1), diags.failureCount());
|
||||
try testing.expectEqualStrings("nope.zon", diags.problems.items[0].path);
|
||||
try testing.expectEqualStrings("no such file", diags.problems.items[0].message);
|
||||
}
|
||||
|
||||
test "a directory named as the managed file is a configuration fault, not a crash" {
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.createDirPath(testing.io, "sub");
|
||||
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
try testing.expectError(
|
||||
error.ManagedConfigUnreadable,
|
||||
load(testing.io, arena_state.allocator(), tmp.dir, "sub", &diags),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 1), diags.failureCount());
|
||||
}
|
||||
|
||||
test "load parses a valid file and renders a syntax error line by line" {
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.writeFile(testing.io, .{ .sub_path = "good.zon", .data =
|
||||
\\.{
|
||||
\\ .groups = .{ .{ .name = "default" } },
|
||||
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
|
||||
\\}
|
||||
});
|
||||
try tmp.dir.writeFile(testing.io, .{ .sub_path = "bad.zon", .data = ".{ .groups = " });
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
var good_diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer good_diags.deinit();
|
||||
const cfg = try load(testing.io, arena, tmp.dir, "good.zon", &good_diags);
|
||||
try testing.expectEqual(@as(usize, 0), good_diags.problems.items.len);
|
||||
try testing.expectEqual(@as(usize, 1), cfg.upstreams.len);
|
||||
|
||||
var bad_diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer bad_diags.deinit();
|
||||
try testing.expectError(
|
||||
error.ParseZon,
|
||||
load(testing.io, arena, tmp.dir, "bad.zon", &bad_diags),
|
||||
);
|
||||
try testing.expect(bad_diags.failureCount() >= 1);
|
||||
try testing.expectEqualStrings("config", bad_diags.problems.items[0].path);
|
||||
}
|
||||
+76
-11
@@ -87,10 +87,16 @@ pub const Web = struct {
|
||||
enabled: bool = true,
|
||||
bind: []const u8 = "0.0.0.0",
|
||||
port: u16 = 8080,
|
||||
/// Operator input only. Never a settings row, always exported as "".
|
||||
password: []const u8 = "",
|
||||
/// argon2id PHC string; "" disables authentication.
|
||||
password_hash: []const u8 = "",
|
||||
/// Operator input only. Never a settings row, never exported.
|
||||
///
|
||||
/// Optional because absence and emptiness are different declarations: null
|
||||
/// means "the file says nothing about the password, keep the stored hash",
|
||||
/// while a present value is an instruction to set one.
|
||||
password: ?[]const u8 = null,
|
||||
/// argon2id PHC string. Null means "the file says nothing, keep what is
|
||||
/// stored"; an explicit `""` is the documented way to disable
|
||||
/// authentication.
|
||||
password_hash: ?[]const u8 = null,
|
||||
session_ttl_hours: u16 = 24,
|
||||
api_rate_limit_per_min: u32 = 300,
|
||||
/// Requests from the box itself skip the API rate limit. On by default: a
|
||||
@@ -387,9 +393,23 @@ fn isScalarSection(comptime T: type) bool {
|
||||
return @typeInfo(T) == .@"struct";
|
||||
}
|
||||
|
||||
/// `web.password` is operator input, never a settings row: it is hashed into
|
||||
/// `web.password_hash` at import time and discarded (S2.5).
|
||||
fn isSkipped(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
/// The skip policy splits by direction, because encode and decode need
|
||||
/// different sets.
|
||||
///
|
||||
/// `web.password` is operator input and is skipped both ways: it is hashed into
|
||||
/// `web.password_hash` and discarded (S2.5).
|
||||
///
|
||||
/// `web.password_hash` is skipped on **encode only**. The reconcile engine owns
|
||||
/// that settings row directly — ruling 4 of milestone 20 makes absence mean
|
||||
/// "keep the stored hash", which a general encode pass cannot express. Skipping
|
||||
/// it on decode as well would leave `cfg.web.password_hash` null on every read
|
||||
/// path, turn `auth.authEnabled` false, and silently open the admin UI.
|
||||
fn isEncodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
if (!std.mem.eql(u8, section, "web")) return false;
|
||||
return std.mem.eql(u8, field, "password") or std.mem.eql(u8, field, "password_hash");
|
||||
}
|
||||
|
||||
fn isDecodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
|
||||
}
|
||||
|
||||
@@ -416,6 +436,10 @@ fn decodeValue(comptime T: type, text: []const u8) error{BadSettingValue}!T {
|
||||
.int => std.fmt.parseInt(T, text, 10) catch error.BadSettingValue,
|
||||
.@"enum" => T.fromDb(text) orelse error.BadSettingValue,
|
||||
.pointer => text,
|
||||
// A stored key is a present value, so an optional field decodes to a
|
||||
// non-null one; the null stays reserved for the absent key, which never
|
||||
// reaches this function at all.
|
||||
.optional => |info| try decodeValue(info.child, text),
|
||||
else => @compileError("unsupported setting field type " ++ @typeName(T)),
|
||||
};
|
||||
}
|
||||
@@ -441,7 +465,7 @@ pub fn toSettings(cfg: Config, gpa: Allocator, out: *std.ArrayList(SettingPair))
|
||||
if (comptime isScalarSection(section_field.type)) {
|
||||
const section = @field(cfg, section_field.name);
|
||||
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
if (comptime !isSkipped(section_field.name, field.name)) {
|
||||
if (comptime !isEncodeSkipped(section_field.name, field.name)) {
|
||||
const value = try encodeValue(field.type, @field(section, field.name), gpa);
|
||||
errdefer gpa.free(value);
|
||||
try out.append(gpa, .{ .key = section_field.name ++ "." ++ field.name, .value = value });
|
||||
@@ -465,7 +489,7 @@ pub fn fromSettings(pairs: []const SettingPair, cfg: *Config, unknown_keys: *usi
|
||||
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
|
||||
if (comptime isScalarSection(section_field.type)) {
|
||||
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
if (comptime !isSkipped(section_field.name, field.name)) {
|
||||
if (comptime !isDecodeSkipped(section_field.name, field.name)) {
|
||||
if (std.mem.eql(u8, pair.key, section_field.name ++ "." ++ field.name)) {
|
||||
@field(@field(cfg, section_field.name), field.name) =
|
||||
try decodeValue(field.type, pair.value);
|
||||
@@ -531,7 +555,6 @@ const expected_keys = [_][]const u8{
|
||||
"web.api_rate_limit_per_min",
|
||||
"web.bind",
|
||||
"web.enabled",
|
||||
"web.password_hash",
|
||||
"web.port",
|
||||
"web.session_ttl_hours",
|
||||
"web.sse_max_connections_per_ip",
|
||||
@@ -642,7 +665,7 @@ test "toSettings and fromSettings round-trip a non-default config" {
|
||||
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
|
||||
if (comptime isScalarSection(section_field.type)) {
|
||||
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
if (comptime !isSkipped(section_field.name, field.name)) {
|
||||
if (comptime !isEncodeSkipped(section_field.name, field.name)) {
|
||||
const a = @field(@field(original, section_field.name), field.name);
|
||||
const b = @field(@field(restored, section_field.name), field.name);
|
||||
if (comptime @typeInfo(field.type) == .pointer) {
|
||||
@@ -671,6 +694,48 @@ test "an unknown settings key is counted and not an error" {
|
||||
try testing.expectEqual(@as(usize, 2), unknown);
|
||||
}
|
||||
|
||||
test "web.password_hash decodes from the settings table but is never encoded" {
|
||||
const gpa = testing.allocator;
|
||||
var pairs: std.ArrayList(SettingPair) = .empty;
|
||||
defer {
|
||||
freeSettings(gpa, pairs.items);
|
||||
pairs.deinit(gpa);
|
||||
}
|
||||
|
||||
// Encode: the reconciler owns that row, so no pass over the model emits it.
|
||||
const hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def";
|
||||
try toSettings(.{ .web = .{ .password_hash = hash } }, gpa, &pairs);
|
||||
for (pairs.items) |pair| {
|
||||
try testing.expect(!std.mem.eql(u8, pair.key, "web.password_hash"));
|
||||
try testing.expect(!std.mem.eql(u8, pair.key, "web.password"));
|
||||
}
|
||||
|
||||
// Decode: every read path still sees the stored hash, or `authEnabled`
|
||||
// would read false on a box that has a password set.
|
||||
var cfg: Config = .{};
|
||||
var unknown: usize = 0;
|
||||
const stored = [_]SettingPair{.{ .key = "web.password_hash", .value = hash }};
|
||||
try fromSettings(&stored, &cfg, &unknown);
|
||||
try testing.expectEqual(@as(usize, 0), unknown);
|
||||
try testing.expectEqualStrings(hash, cfg.web.password_hash.?);
|
||||
}
|
||||
|
||||
test "an optional settings field is null when absent and non-null when present" {
|
||||
var absent: Config = .{};
|
||||
var unknown: usize = 0;
|
||||
const other = [_]SettingPair{.{ .key = "dns.port", .value = "5300" }};
|
||||
try fromSettings(&other, &absent, &unknown);
|
||||
try testing.expectEqual(@as(?[]const u8, null), absent.web.password_hash);
|
||||
|
||||
// An explicit empty string is a present value, not an absent key: it is how
|
||||
// a config file disables authentication.
|
||||
var empty: Config = .{};
|
||||
const disabled = [_]SettingPair{.{ .key = "web.password_hash", .value = "" }};
|
||||
try fromSettings(&disabled, &empty, &unknown);
|
||||
try testing.expect(empty.web.password_hash != null);
|
||||
try testing.expectEqualStrings("", empty.web.password_hash.?);
|
||||
}
|
||||
|
||||
test "a malformed settings value is BadSettingValue" {
|
||||
var cfg: Config = .{};
|
||||
var unknown: usize = 0;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+56
-2
@@ -101,6 +101,7 @@ pub const ValidateError = error{
|
||||
MissingKeyPath,
|
||||
MissingLogPath,
|
||||
PasswordAndHashBothSet,
|
||||
EmptyWebPassword,
|
||||
};
|
||||
|
||||
/// What `validate` returns: a verdict on the configuration, or the allocation
|
||||
@@ -114,7 +115,19 @@ pub const Error = ValidateError || Allocator.Error;
|
||||
/// same channel so a syntax error's line/column reaches the operator's output,
|
||||
/// plus the warnings — which are never returned by `validate` and so are not
|
||||
/// `ValidateError` members.
|
||||
pub const ProblemError = ValidateError || error{ ParseZon, SourceInNoGroup };
|
||||
/// Wider than `ValidateError`: the diagnostic channel also carries the problems
|
||||
/// found before the validator ever sees a `Config` — the ZON parse, the managed
|
||||
/// file that would not open (`config/loader.zig`), the file above the size limit
|
||||
/// — and the one found after it, an import whose diff would delete rows. The
|
||||
/// validator itself records only `ValidateError` members, which is what makes
|
||||
/// `validate`'s `@errorCast` of its own findings checked-safe.
|
||||
pub const ProblemError = ValidateError || error{
|
||||
ParseZon,
|
||||
SourceInNoGroup,
|
||||
ManagedConfigUnreadable,
|
||||
ConfigTooLarge,
|
||||
DestructiveImport,
|
||||
};
|
||||
|
||||
/// `.fail` rejects the configuration and is what an exit code is computed from.
|
||||
/// `.warn` reports something legal that is almost certainly not what the
|
||||
@@ -392,7 +405,10 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
|
||||
|
||||
try checkBind(diags, cfg.web.bind, "web.bind", .any);
|
||||
try checkPort(diags, cfg.web.port, "web.port");
|
||||
if (cfg.web.password.len != 0 and cfg.web.password_hash.len != 0) {
|
||||
// Both fields are optional, and absence is the third state: a file that
|
||||
// states neither keeps the stored hash. So the test is on presence, not on
|
||||
// length.
|
||||
if (cfg.web.password != null and cfg.web.password_hash != null) {
|
||||
try diags.add(
|
||||
error.PasswordAndHashBothSet,
|
||||
"web.password",
|
||||
@@ -401,6 +417,22 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
|
||||
.{},
|
||||
);
|
||||
}
|
||||
// A present-but-empty password would hash the empty string into a non-empty
|
||||
// PHC — authentication on — while every login with an empty password is
|
||||
// refused: authentication on and unreachable. The remedy is named, because
|
||||
// the operator who wrote this meant one of two other things.
|
||||
if (cfg.web.password) |password| {
|
||||
if (password.len == 0) {
|
||||
try diags.add(
|
||||
error.EmptyWebPassword,
|
||||
"web.password",
|
||||
.{},
|
||||
"password is set to the empty string; omit the field to keep the stored password, " ++
|
||||
"or set password_hash = \"\" to disable authentication",
|
||||
.{},
|
||||
);
|
||||
}
|
||||
}
|
||||
// A session TTL is a TTL; `BadTtl` is its bucket.
|
||||
if (cfg.web.session_ttl_hours < 1) {
|
||||
try diags.add(error.BadTtl, "web.session_ttl_hours", .{}, "must be at least 1", .{});
|
||||
@@ -1968,6 +2000,28 @@ test "error.PasswordAndHashBothSet" {
|
||||
try expectProblem(cfg, error.PasswordAndHashBothSet, "web.password");
|
||||
}
|
||||
|
||||
test "error.EmptyWebPassword names password_hash as the way to disable auth" {
|
||||
var cfg = baseConfig();
|
||||
cfg.web.password = "";
|
||||
try expectProblem(cfg, error.EmptyWebPassword, "web.password");
|
||||
|
||||
// The remedy has to be in the text: the operator who wrote `password = ""`
|
||||
// meant either "keep the current one" or "turn authentication off", and the
|
||||
// diagnostic is the only place that distinction is spelled out.
|
||||
var diags: Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
try testing.expectError(error.EmptyWebPassword, validate(cfg, &diags));
|
||||
try testing.expect(std.mem.indexOf(u8, diags.problems.items[0].message, "password_hash = \"\"") != null);
|
||||
|
||||
// Absence is not emptiness: a file that states no password is legal and
|
||||
// means "keep the stored hash".
|
||||
var absent = baseConfig();
|
||||
absent.web.password = null;
|
||||
var quiet: Diagnostics = .init(testing.allocator);
|
||||
defer quiet.deinit();
|
||||
try validate(absent, &quiet);
|
||||
}
|
||||
|
||||
test "a config with five distinct problems yields five diagnostics and the first error" {
|
||||
var cfg = baseConfig();
|
||||
cfg.dns.port = 0; // BadPort, first in check order
|
||||
|
||||
@@ -22,6 +22,7 @@ const build_options = @import("build_options");
|
||||
const net = std.Io.net;
|
||||
|
||||
const model = @import("../config/model.zig");
|
||||
const reconcile = @import("../config/reconcile.zig");
|
||||
const db = @import("../storage/db.zig");
|
||||
const migrations = @import("../storage/migrations.zig");
|
||||
const context = @import("../storage/repositories/context.zig");
|
||||
@@ -400,6 +401,10 @@ const HttpFixture = struct {
|
||||
server: net.Server,
|
||||
body: []const u8,
|
||||
route: std.atomic.Value(u8),
|
||||
/// Connections accepted, whatever came over them. A test that claims a pass
|
||||
/// downloaded nothing reads this rather than the route counters: a refetch
|
||||
/// that failed on the wire is still a refetch, and this counts it.
|
||||
accepted: std.atomic.Value(u32),
|
||||
/// How many parts the `chunked` route has flushed. The test reads it to
|
||||
/// prove the reply really left this server in pieces, because a `Writer`
|
||||
/// reports a buffered part as written and would otherwise hide a fixture
|
||||
@@ -422,6 +427,7 @@ const HttpFixture = struct {
|
||||
.server = try local.listen(io, .{ .reuse_address = true }),
|
||||
.body = body,
|
||||
.route = .init(@intFromEnum(Route.body)),
|
||||
.accepted = .init(0),
|
||||
.flushed_parts = .init(0),
|
||||
.stall_reached = .unset,
|
||||
.stall_release = .unset,
|
||||
@@ -449,6 +455,7 @@ const HttpFixture = struct {
|
||||
while (true) {
|
||||
var stream = self.server.accept(io) catch return;
|
||||
defer stream.close(io);
|
||||
_ = self.accepted.fetchAdd(1, .monotonic);
|
||||
|
||||
var read_buf: [8192]u8 = undefined;
|
||||
var write_buf: [8192]u8 = undefined;
|
||||
@@ -1372,6 +1379,112 @@ test "10d: a source deleted mid-refresh does not take the refresh's temporary fi
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10e: the restart invariant, across the config engine and the filter layer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "10e: a reconcile then a restart reuses the compiled files and downloads nothing" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
const env = try Env.create(gpa);
|
||||
defer env.destroy();
|
||||
const io = env.io();
|
||||
|
||||
var fixture = try HttpFixture.init(io, http_body);
|
||||
defer fixture.deinit(io);
|
||||
var group: std.Io.Group = .init;
|
||||
defer group.cancel(io);
|
||||
try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });
|
||||
|
||||
var url_buf: [64]u8 = undefined;
|
||||
const url = try fixture.url(&url_buf);
|
||||
const id = try seedSource(&env.database, url);
|
||||
|
||||
// One real download, so the compiled artifacts exist and are named after
|
||||
// the row id the rest of this test is about.
|
||||
try testing.expect(try refreshOnce(env, url));
|
||||
try testing.expectEqual(@as(u32, 1), fixture.accepted.load(.monotonic));
|
||||
|
||||
var dir = try env.blocklistDir();
|
||||
defer dir.close(io);
|
||||
var list_buf: [64]u8 = undefined;
|
||||
var wild_buf: [64]u8 = undefined;
|
||||
const list_name = try std.fmt.bufPrint(&list_buf, "{d}.list", .{id});
|
||||
const wild_name = try std.fmt.bufPrint(&wild_buf, "{d}.wild", .{id});
|
||||
const list_before = try dir.statFile(io, list_name, .{});
|
||||
const wild_before = try dir.statFile(io, wild_name, .{});
|
||||
|
||||
// File mode, declaring exactly what the database already holds. The engine
|
||||
// has to recognise the source by its url and leave the row where it is:
|
||||
// the compiled files are named after that id, and the manager looks for
|
||||
// them under the same number.
|
||||
const cfg: model.Config = .{
|
||||
.groups = &.{.{ .name = "default" }},
|
||||
.blocklist_sources = &.{.{ .url = url, .name = source_name }},
|
||||
.group_sources = &.{.{ .group = "default", .source_url = url }},
|
||||
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
|
||||
};
|
||||
var pass = try reconcile.begin(
|
||||
io,
|
||||
gpa,
|
||||
&env.database,
|
||||
cfg,
|
||||
std.Io.Clock.real.now(io).toSeconds(),
|
||||
.{},
|
||||
);
|
||||
errdefer pass.rollback();
|
||||
// Committed before the manager comes up, which is the ordering the invariant
|
||||
// rests on: a manager that read the table mid-transaction could see either
|
||||
// half of a source it is about to look for on disk.
|
||||
try pass.commit();
|
||||
|
||||
// The restart. The old manager is gone and a new one comes up over the same
|
||||
// directory and the same database with nothing carried across in memory.
|
||||
// `runScheduler` is the boot sequence the server runs — the orphan sweep,
|
||||
// then the startup pass — and a disabled update makes it return rather than
|
||||
// wait out an interval.
|
||||
env.mgr.deinit(io);
|
||||
env.mgr = try manager.Manager.init(
|
||||
gpa,
|
||||
&env.database,
|
||||
.{ .dir = env.tmp.dir },
|
||||
&env.f,
|
||||
.{ .enabled = false },
|
||||
budget,
|
||||
);
|
||||
try env.mgr.runScheduler(io);
|
||||
|
||||
// Nothing was downloaded. The server is still listening, so this is a
|
||||
// decision the pass made rather than a connection it could not have opened.
|
||||
try testing.expectEqual(@as(u32, 1), fixture.accepted.load(.monotonic));
|
||||
|
||||
// The same two files: not recompiled, and not swept as orphans and written
|
||||
// back.
|
||||
const list_after = try dir.statFile(io, list_name, .{});
|
||||
const wild_after = try dir.statFile(io, wild_name, .{});
|
||||
try testing.expectEqual(list_before.inode, list_after.inode);
|
||||
try testing.expectEqual(list_before.mtime, list_after.mtime);
|
||||
try testing.expectEqual(wild_before.inode, wild_after.inode);
|
||||
try testing.expectEqual(wild_before.mtime, wild_after.mtime);
|
||||
|
||||
// The row kept the id those files are named after, and the snapshot the
|
||||
// restart published is the one compiled from them.
|
||||
var rows = try listRows(&env.database);
|
||||
defer rows.deinit();
|
||||
try testing.expectEqual(id, (try rows.byUrl(url)).id);
|
||||
try testing.expectEqual(manager.State.ok, (try env.status(id)).state);
|
||||
|
||||
// Asserted last, after the behaviour it explains: the engine wrote no row
|
||||
// at all, which is why the id above survived and why the restart above had
|
||||
// files to find.
|
||||
try testing.expectEqual(@as(u32, 0), pass.summary.sources.total());
|
||||
|
||||
const decision, _ = try env.evaluate("ads.example.com");
|
||||
try testing.expect(decision.blocked);
|
||||
try testing.expectEqual(matcher.Reason.blocklist_domain, decision.reason);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 11–12: local records, from the database to the wire
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1199,6 +1199,14 @@ pub const Manager = struct {
|
||||
|
||||
if (state != .ok) return true;
|
||||
const last = row.last_updated orelse return true;
|
||||
// The Pi has no RTC, so a fetch stamped while the clock ran ahead of
|
||||
// real time (a pre-NTP boot, a restored image) leaves a `last_updated`
|
||||
// in the future. Plain interval arithmetic would then suspend every
|
||||
// refresh until real time caught up with the poison stamp, and the
|
||||
// reconcile engine preserves runtime columns faithfully, so nothing
|
||||
// else would ever clear it. A stamp from the future is not evidence of
|
||||
// a recent fetch.
|
||||
if (last > now) return true;
|
||||
return now - last >= model.updateIntervalSeconds(self.update);
|
||||
}
|
||||
|
||||
@@ -1674,6 +1682,40 @@ test "acquire before any reload returns null and holds no lock" {
|
||||
manager.lock.unlock(io);
|
||||
}
|
||||
|
||||
test "needsRefresh treats a last_updated in the future as due" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var f: fetcher.Fetcher = undefined;
|
||||
var manager = try testManager(&database, &f);
|
||||
defer manager.deinit(io);
|
||||
|
||||
// `.ok` is the only state that consults the clock at all; every other one
|
||||
// is already due, so the arithmetic below would be unreachable without it.
|
||||
var statuses = [_]SourceStatus{.{ .id = 1, .state = .ok }};
|
||||
manager.statuses = &statuses;
|
||||
defer manager.statuses = &.{};
|
||||
|
||||
const row = testRow(1, true);
|
||||
const stamp = row.last_updated.?;
|
||||
const interval = model.updateIntervalSeconds(manager.update);
|
||||
|
||||
// The ordinary cases still hold: fresh is not due, stale is.
|
||||
try testing.expect(!manager.needsRefresh(io, row, stamp + 1));
|
||||
try testing.expect(manager.needsRefresh(io, row, stamp + interval));
|
||||
|
||||
// The Pi has no RTC. A fetch stamped while the clock ran ahead of real
|
||||
// time leaves `now - last` negative, which reads as "fetched moments ago"
|
||||
// and suspends every refresh until real time catches the poison stamp —
|
||||
// for a whole day here, and for as long as the clock was wrong in general.
|
||||
try testing.expect(manager.needsRefresh(io, row, stamp - 1));
|
||||
try testing.expect(manager.needsRefresh(io, row, stamp - 86_400));
|
||||
}
|
||||
|
||||
test "the disk gate skips a scheduled refresh only while writes are critical" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
@@ -965,10 +965,12 @@ test "S7 case 11: the app boots, serves a query and exits zero on shutdown" {
|
||||
shutdown.reset();
|
||||
defer shutdown.reset();
|
||||
|
||||
var future = try test_io.concurrent(app.run, .{ runner, cli.RunArgs{ .paths = .{
|
||||
.data_dir = root,
|
||||
// `--config` present: the file is authority and the database is converged
|
||||
// onto it before the listeners bind (milestone-20 ruling 1).
|
||||
var future = try test_io.concurrent(app.run, .{ runner, cli.RunArgs{
|
||||
.paths = .{ .data_dir = root },
|
||||
.config = config_path,
|
||||
} } });
|
||||
} });
|
||||
|
||||
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
const client = try client_address.bind(test_io, .{ .mode = .dgram });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The `config.db` schema, verbatim from PLAN §11.2, plus the two table orders
|
||||
//! every other storage session needs.
|
||||
//! The `config.db` schema, verbatim from PLAN §11.2, plus the table lists every
|
||||
//! other storage session needs.
|
||||
//!
|
||||
//! The DDL text is data, not code: `migrations.zig` carries it as step 1 and
|
||||
//! never edits it in place. A schema change is a *new* step with new DDL, so
|
||||
@@ -89,8 +89,10 @@ pub const ddl_v1: [:0]const u8 =
|
||||
\\CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
;
|
||||
|
||||
/// Child-before-parent. Used by import's wipe step; correct under
|
||||
/// `foreign_keys = ON`.
|
||||
/// Child-before-parent, and correct under `foreign_keys = ON`. The reconcile
|
||||
/// engine deletes in this order so that every declarative child of a dying
|
||||
/// parent is removed — and counted — before the parent goes, which keeps the FK
|
||||
/// cascades a safety net rather than the accountant.
|
||||
///
|
||||
/// `upstreams`, `local_records`, `forward_zones` and `settings` have no foreign
|
||||
/// keys, so their position is free; `groups` and `blocklist_sources` must come
|
||||
@@ -102,18 +104,28 @@ pub const delete_order = [_][]const u8{
|
||||
"blocklist_sources", "groups",
|
||||
};
|
||||
|
||||
/// Every table whose emptiness defines "the database has never been configured"
|
||||
/// (S5.2). `groups` is absent because migration step 1 seeds `(1, 'default')`,
|
||||
/// so an empty database still holds one group row; `schema_version` is absent
|
||||
/// for the same reason.
|
||||
pub const content_tables = [_][]const u8{
|
||||
"clients", "client_prefixes", "upstreams", "blocklist_sources",
|
||||
"group_sources", "rules", "local_records", "forward_zones",
|
||||
"settings",
|
||||
/// Every table that holds configuration, in a fixed order. It includes
|
||||
/// `groups`, because a byte-stability dump has to be able to see a reconcile
|
||||
/// that renumbered a group.
|
||||
///
|
||||
/// `schema_version` is absent: it is the migration's, not the operator's.
|
||||
pub const table_names = [_][]const u8{
|
||||
"groups", "clients", "client_prefixes", "upstreams",
|
||||
"blocklist_sources", "group_sources", "rules", "local_records",
|
||||
"forward_zones", "settings",
|
||||
};
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "table_names names exactly the tables delete_order does" {
|
||||
try testing.expectEqual(delete_order.len, table_names.len);
|
||||
for (delete_order) |name| {
|
||||
try testing.expect(indexOf(&table_names, name) != null);
|
||||
}
|
||||
try testing.expect(indexOf(&table_names, "groups") != null);
|
||||
try testing.expect(indexOf(&table_names, "schema_version") == null);
|
||||
}
|
||||
|
||||
test "delete_order lists every referrer before the table it references" {
|
||||
// The two parents in the schema. Every child that references them must be
|
||||
// deleted first, or `foreign_keys = ON` turns import's wipe into a
|
||||
@@ -130,15 +142,6 @@ test "delete_order lists every referrer before the table it references" {
|
||||
}
|
||||
}
|
||||
|
||||
test "content_tables is delete_order without groups" {
|
||||
try testing.expectEqual(delete_order.len - 1, content_tables.len);
|
||||
for (content_tables) |name| {
|
||||
try testing.expect(indexOf(&delete_order, name) != null);
|
||||
}
|
||||
try testing.expect(indexOf(&content_tables, "groups") == null);
|
||||
try testing.expect(indexOf(&content_tables, "schema_version") == null);
|
||||
}
|
||||
|
||||
fn indexOf(haystack: []const []const u8, needle: []const u8) ?usize {
|
||||
for (haystack, 0..) |item, i| {
|
||||
if (std.mem.eql(u8, item, needle)) return i;
|
||||
|
||||
@@ -57,6 +57,7 @@ pub const c = struct {
|
||||
pub extern fn sqlite3_column_bytes(stmt: *c.Stmt, col: c_int) c_int;
|
||||
pub extern fn sqlite3_last_insert_rowid(db: *Sqlite3) i64;
|
||||
pub extern fn sqlite3_changes(db: *Sqlite3) c_int;
|
||||
pub extern fn sqlite3_total_changes(db: *Sqlite3) c_int;
|
||||
};
|
||||
|
||||
/// Result codes, from the vendored `sqlite3.h` (3.53.4).
|
||||
@@ -429,6 +430,15 @@ pub const Db = struct {
|
||||
pub fn changes(self: *Db) i64 {
|
||||
return c.sqlite3_changes(self.handle);
|
||||
}
|
||||
|
||||
/// Every row this connection has inserted, updated or deleted since it was
|
||||
/// opened. Monotonic, so a caller proves "this call wrote nothing" by
|
||||
/// reading it either side and comparing — which is stronger than comparing
|
||||
/// content, because an UPDATE that rewrites identical values still moves
|
||||
/// this counter.
|
||||
pub fn totalChanges(self: *Db) i64 {
|
||||
return c.sqlite3_total_changes(self.handle);
|
||||
}
|
||||
};
|
||||
|
||||
fn openHandle(filename: [:0]const u8, flags: c_int) Error!*c.Sqlite3 {
|
||||
|
||||
@@ -353,7 +353,7 @@ test "readVersion reads a file database through an immutable open, writing nothi
|
||||
try testing.expectEqual(@as(u32, 1), try readVersion(&database));
|
||||
}
|
||||
|
||||
test "delete_order and content_tables name exactly the tables the schema creates" {
|
||||
test "delete_order and table_names name exactly the tables the schema creates" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
_ = try migrate(&database);
|
||||
@@ -361,7 +361,7 @@ test "delete_order and content_tables name exactly the tables the schema creates
|
||||
for (config_schema.delete_order) |name| {
|
||||
try testing.expect(try tableExists(&database, name));
|
||||
}
|
||||
for (config_schema.content_tables) |name| {
|
||||
for (config_schema.table_names) |name| {
|
||||
try testing.expect(try tableExists(&database, name));
|
||||
}
|
||||
// delete_order covers every table except `schema_version`.
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
//! `listClients` returns only `hand_edited = 1` rows. A client the server
|
||||
//! materialised from live traffic is runtime state, not configuration, and must
|
||||
//! not appear in an export. `hand_edited` is the only marker of operator intent
|
||||
//! in this table, so it also decides what `import.isEmpty` counts: a database
|
||||
//! carrying nothing but materialised rows has never been configured, and a seed
|
||||
//! file must still be able to fill it. `countClients` counts **all** rows and is
|
||||
//! a test helper — it deliberately does not answer that question.
|
||||
//! in this table, so it also decides what the reconcile engine may delete: a
|
||||
//! declared row the file drops is removed, an observed row is kept whatever the
|
||||
//! file says, and declaring an observed address promotes that row in place.
|
||||
//! `countClients` counts **all** rows and is a test helper.
|
||||
//!
|
||||
//! The import path is list / insert / deleteAll / count, plus the two runtime
|
||||
//! calls `upsertSeen` and `pruneStale` that `server/clients.zig`'s tracker owns.
|
||||
//! The configuration path is list / insert / update / delete / count, plus the
|
||||
//! two runtime calls `upsertSeen` and `pruneStale` that `server/clients.zig`'s
|
||||
//! tracker owns.
|
||||
//! The REST surface is the third section: it speaks row ids and shows
|
||||
//! every client, materialised ones included.
|
||||
|
||||
@@ -126,7 +127,7 @@ pub fn deleteAllClients(database: *db.Db) db.Error!void {
|
||||
}
|
||||
|
||||
/// Counts every row, including the materialised ones `listClients` filters out.
|
||||
/// Used by tests; `import.isEmpty` counts operator intent instead.
|
||||
/// Used by tests.
|
||||
pub fn countClients(database: *db.Db) db.Error!i64 {
|
||||
return database.queryInt("SELECT count(*) FROM clients");
|
||||
}
|
||||
@@ -407,6 +408,67 @@ pub fn replaceClientPrefixes(database: *db.Db, items: []const ClientPrefixInput)
|
||||
try tx.commit();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reconcile surface (milestone 20)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// `replaceClientPrefixes` above is the REST list resource: one atomic swap of
|
||||
// the whole table, in a transaction of its own. The reconcile engine cannot use
|
||||
// it — it runs inside a transaction already, and rewriting every row would
|
||||
// forfeit the row ids and the zero-writes property the engine exists for — so
|
||||
// it edits and removes prefixes one at a time instead.
|
||||
|
||||
/// Writes the two columns a prefix row carries besides its identity.
|
||||
///
|
||||
/// `error.NotFound`: no prefix holds `id`. `error.Constraint`:
|
||||
/// `client_prefixes.prefix` is UNIQUE, or `group_id` names no group.
|
||||
pub fn updateClientPrefix(database: *db.Db, id: i64, item: ClientPrefixInput) db.Error!void {
|
||||
var stmt = try database.prepare(
|
||||
"UPDATE client_prefixes SET prefix = ?2, group_id = ?3, priority = ?4 WHERE id = ?1",
|
||||
);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
try stmt.bindText(2, item.prefix);
|
||||
try stmt.bindInt(3, item.group_id);
|
||||
try stmt.bindInt(4, item.priority);
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
/// `error.NotFound`: no prefix holds `id`. Nothing references
|
||||
/// `client_prefixes`, so a delete cannot violate a constraint.
|
||||
pub fn deleteClientPrefix(database: *db.Db, id: i64) db.Error!void {
|
||||
var stmt = try database.prepare("DELETE FROM client_prefixes WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
/// Moves the observed clients of one group to another, and reports how many
|
||||
/// rows moved.
|
||||
///
|
||||
/// `clients.group_id` references `groups(id)` with no `ON DELETE` action
|
||||
/// (config_schema.zig:26), so a group that any client still sits in cannot be
|
||||
/// deleted. When a configuration stops declaring a group, its *declared*
|
||||
/// clients go with it, but the devices the DNS path materialised into it did
|
||||
/// not come from the configuration and must not be deleted for a decision that
|
||||
/// was never about them. They move to the default group, which is also the
|
||||
/// semantics the operator asked for: they un-declared the group, not the
|
||||
/// devices.
|
||||
///
|
||||
/// `hand_edited = 1` rows are untouched — those are configuration, and the
|
||||
/// reconcile engine has already accounted for them.
|
||||
pub fn reassignObservedClients(database: *db.Db, from_group_id: i64, to_group_id: i64) db.Error!u32 {
|
||||
var stmt = try database.prepare(
|
||||
"UPDATE clients SET group_id = ?2 WHERE hand_edited = 0 AND group_id = ?1",
|
||||
);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, from_group_id);
|
||||
try stmt.bindInt(2, to_group_id);
|
||||
try stmt.exec();
|
||||
const moved = database.changes();
|
||||
return @intCast(@min(moved, std.math.maxInt(u32)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -254,6 +254,41 @@ pub fn setGroupSources(database: *db.Db, group_id: i64, source_ids: []const i64)
|
||||
try tx.commit();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reconcile surface (milestone 20)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// `group_sources` has no row id — the pair *is* the identity — so the reconcile
|
||||
// engine matches on the pair and needs the ids the name-keyed list above
|
||||
// resolves away.
|
||||
|
||||
pub const GroupSourcePair = struct { group_id: i64, source_id: i64 };
|
||||
|
||||
/// Every assignment as the pair of ids it is. Nothing to free.
|
||||
pub fn listGroupSourcePairs(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(GroupSourcePair) {
|
||||
return crud.listRows(
|
||||
GroupSourcePair,
|
||||
database,
|
||||
gpa,
|
||||
"SELECT group_id, source_id FROM group_sources ORDER BY group_id, source_id",
|
||||
readGroupSourcePair,
|
||||
);
|
||||
}
|
||||
|
||||
fn readGroupSourcePair(stmt: *db.Stmt, gpa: Allocator) db.Error!GroupSourcePair {
|
||||
_ = gpa;
|
||||
return .{ .group_id = stmt.columnInt(0), .source_id = stmt.columnInt(1) };
|
||||
}
|
||||
|
||||
/// Removes one assignment. `error.NotFound`: no row holds the pair.
|
||||
pub fn deleteGroupSourcePair(database: *db.Db, pair: GroupSourcePair) db.Error!void {
|
||||
var stmt = try database.prepare("DELETE FROM group_sources WHERE group_id = ?1 AND source_id = ?2");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, pair.group_id);
|
||||
try stmt.bindInt(2, pair.source_id);
|
||||
return crud.execStrict(database, &stmt);
|
||||
}
|
||||
|
||||
fn groupExists(database: *db.Db, id: i64) db.Error!bool {
|
||||
var stmt = try database.prepare("SELECT 1 FROM groups WHERE id = ?1");
|
||||
defer stmt.deinit();
|
||||
|
||||
@@ -88,6 +88,18 @@ pub fn putSetting(database: *db.Db, key: []const u8, value: []const u8) db.Error
|
||||
try stmt.exec();
|
||||
}
|
||||
|
||||
/// Removes one key. Silent about a key that is not stored: the reconcile
|
||||
/// engine's sweep computes the set of keys to drop from a list it has already
|
||||
/// read, so "no such row" is not a caller error the way it is for a by-id
|
||||
/// mutation, and `execStrict` would turn a harmless race into a failed
|
||||
/// transaction.
|
||||
pub fn deleteSetting(database: *db.Db, key: []const u8) db.Error!void {
|
||||
var stmt = try database.prepare("DELETE FROM settings WHERE key = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindText(1, key);
|
||||
return stmt.exec();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -22,7 +22,6 @@ const build_options = @import("build_options");
|
||||
const Writer = std.Io.Writer;
|
||||
|
||||
const cli = @import("../cli.zig");
|
||||
const bootstrap = @import("../config/bootstrap.zig");
|
||||
const config_export = @import("../config/export.zig");
|
||||
const import = @import("../config/import.zig");
|
||||
const model = @import("../config/model.zig");
|
||||
@@ -127,7 +126,7 @@ fn openMigrated(f: *const Fixture, name: []const u8) !Data {
|
||||
return .{ .dir = dir, .database = database };
|
||||
}
|
||||
|
||||
fn importInto(f: *const Fixture, data: *Data, file: []const u8, force: bool) !void {
|
||||
fn importInto(f: *const Fixture, data: *Data, file: []const u8, allow_delete: bool) !void {
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
return import.importFile(
|
||||
@@ -136,7 +135,7 @@ fn importInto(f: *const Fixture, data: *Data, file: []const u8, force: bool) !vo
|
||||
&data.database,
|
||||
f.tmp.dir,
|
||||
file,
|
||||
.{ .force = force },
|
||||
.{ .allow_delete = allow_delete },
|
||||
&diags,
|
||||
);
|
||||
}
|
||||
@@ -655,7 +654,7 @@ test "S7 case 10: a config.db stamped one version ahead is refused and left alon
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// case 11-19: export, import and bootstrap on real files
|
||||
// case 11-19: export and import on real files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "S7 case 11: an exported file is mode 0600 and starts with the header comment" {
|
||||
@@ -704,7 +703,7 @@ test "S7 case 12: export, import and export again are byte-identical files" {
|
||||
try testing.expectEqualStrings(a, b);
|
||||
}
|
||||
|
||||
test "S7 case 13: import refuses a configured database unless --force is given" {
|
||||
test "S7 case 13: import refuses a diff that deletes rows unless --allow-delete is given" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var f: Fixture = .init();
|
||||
@@ -716,7 +715,10 @@ test "S7 case 13: import refuses a configured database unless --force is given"
|
||||
defer data.deinit();
|
||||
try importInto(&f, &data, "first.zon", false);
|
||||
|
||||
try testing.expectError(error.DatabaseNotEmpty, importInto(&f, &data, "second.zon", false));
|
||||
// The two files name different upstream urls, and a url is the upstream's
|
||||
// identity: applying the second deletes the first's row, which is what the
|
||||
// gate exists to stop.
|
||||
try testing.expectError(error.DestructiveImport, importInto(&f, &data, "second.zon", false));
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
try data.database.queryInt(
|
||||
@@ -753,14 +755,15 @@ test "S7 case 14: an invalid import reports every problem and writes nothing" {
|
||||
&data.database,
|
||||
f.tmp.dir,
|
||||
"config.zon",
|
||||
.{ .force = false },
|
||||
.{ .allow_delete = false },
|
||||
&diags,
|
||||
)) |_| {
|
||||
return error.TestUnexpectedResult;
|
||||
} else |_| {}
|
||||
|
||||
try testing.expectEqual(@as(usize, 2), diags.problems.items.len);
|
||||
try testing.expect(try import.isEmpty(&data.database));
|
||||
try testing.expectEqual(@as(i64, 0), try data.database.queryInt("SELECT count(*) FROM upstreams"));
|
||||
try testing.expectEqual(@as(i64, 0), try data.database.queryInt("SELECT count(*) FROM settings"));
|
||||
|
||||
// Nothing beyond the database and its sidecars was created.
|
||||
var dir = try f.tmp.dir.openDir(io, "data", .{ .iterate = true });
|
||||
@@ -771,129 +774,6 @@ test "S7 case 14: an invalid import reports every problem and writes nothing" {
|
||||
}
|
||||
}
|
||||
|
||||
test "S7 case 15: bootstrap with no configuration file leaves the database empty" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var f: Fixture = .init();
|
||||
defer f.deinit();
|
||||
|
||||
var data = try openMigrated(&f, "data");
|
||||
defer data.deinit();
|
||||
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
|
||||
const outcome = try bootstrap.bootstrap(
|
||||
io,
|
||||
testing.allocator,
|
||||
&data.database,
|
||||
f.tmp.dir,
|
||||
"config.zon",
|
||||
&diags,
|
||||
);
|
||||
try testing.expectEqual(bootstrap.Outcome.no_config_file, outcome);
|
||||
try testing.expect(try import.isEmpty(&data.database));
|
||||
}
|
||||
|
||||
test "S7 case 16: bootstrap seeds an empty database from the configuration file" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var f: Fixture = .init();
|
||||
defer f.deinit();
|
||||
try f.write("config.zon", rich_config);
|
||||
|
||||
var data = try openMigrated(&f, "data");
|
||||
defer data.deinit();
|
||||
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
|
||||
const outcome = try bootstrap.bootstrap(
|
||||
io,
|
||||
testing.allocator,
|
||||
&data.database,
|
||||
f.tmp.dir,
|
||||
"config.zon",
|
||||
&diags,
|
||||
);
|
||||
try testing.expectEqual(bootstrap.Outcome.seeded, outcome);
|
||||
try testing.expectEqual(@as(i64, 2), try data.database.queryInt("SELECT count(*) FROM groups"));
|
||||
try testing.expectEqual(@as(i64, 2), try data.database.queryInt("SELECT count(*) FROM upstreams"));
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
try data.database.queryInt("SELECT count(*) FROM clients WHERE ip = 'fd00::1'"),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(i64, 5353),
|
||||
try data.database.queryInt("SELECT CAST(value AS INTEGER) FROM settings WHERE key = 'dns.port'"),
|
||||
);
|
||||
}
|
||||
|
||||
test "S7 case 17: bootstrap on a configured database never reads the file" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var f: Fixture = .init();
|
||||
defer f.deinit();
|
||||
try f.write("seed.zon", minimal_config);
|
||||
|
||||
var data = try openMigrated(&f, "data");
|
||||
defer data.deinit();
|
||||
try importInto(&f, &data, "seed.zon", false);
|
||||
|
||||
// Unparseable on purpose: the call can only succeed if the file is never
|
||||
// opened.
|
||||
try f.write("config.zon", broken_zon);
|
||||
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
|
||||
const outcome = try bootstrap.bootstrap(
|
||||
io,
|
||||
testing.allocator,
|
||||
&data.database,
|
||||
f.tmp.dir,
|
||||
"config.zon",
|
||||
&diags,
|
||||
);
|
||||
try testing.expectEqual(bootstrap.Outcome.db_already_configured, outcome);
|
||||
try testing.expectEqual(@as(usize, 0), diags.problems.items.len);
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
try data.database.queryInt(
|
||||
"SELECT count(*) FROM upstreams WHERE url = 'https://dns.example/dns-query'",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test "S7 case 18: bootstrap with an invalid configuration file fails and writes nothing" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var f: Fixture = .init();
|
||||
defer f.deinit();
|
||||
try f.write("config.zon", two_problem_config);
|
||||
|
||||
var data = try openMigrated(&f, "data");
|
||||
defer data.deinit();
|
||||
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
|
||||
if (bootstrap.bootstrap(
|
||||
io,
|
||||
testing.allocator,
|
||||
&data.database,
|
||||
f.tmp.dir,
|
||||
"config.zon",
|
||||
&diags,
|
||||
)) |outcome| {
|
||||
std.debug.print("bootstrap unexpectedly returned .{s}\n", .{@tagName(outcome)});
|
||||
return error.TestUnexpectedResult;
|
||||
} else |_| {}
|
||||
|
||||
try testing.expectEqual(@as(usize, 2), diags.problems.items.len);
|
||||
try testing.expect(try import.isEmpty(&data.database));
|
||||
}
|
||||
|
||||
test "S7 case 19: writeToFile replaces an existing file and restores mode 0600" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
@@ -988,11 +868,13 @@ test "S7 case 21: runCheck passes a seeded database and reports two stored probl
|
||||
try importInto(&f, &data, "config.zon", false);
|
||||
}
|
||||
{
|
||||
// `applyToDb` rather than an import: the validator would refuse this
|
||||
// `apply` rather than an import: the validator would refuse this
|
||||
// configuration, and the case needs the problems to reach the database.
|
||||
var data = try openMigrated(&f, "bad");
|
||||
defer data.deinit();
|
||||
try import.applyToDb(io, testing.allocator, &data.database, two_problem_model, 42, .{});
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
try import.apply(io, testing.allocator, &data.database, two_problem_model, 42, .{}, &diags);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -1045,13 +927,16 @@ test "S7 case 22: runCheck probes a real upstream and prints an OK line" {
|
||||
|
||||
const code = cli.runCheck(
|
||||
captured.runner(),
|
||||
.{ .paths = .{ .config = config_path }, .config_explicit = true },
|
||||
.{ .config = config_path },
|
||||
true,
|
||||
);
|
||||
try testing.expectEqual(cli.exit_ok, code);
|
||||
// Milestone 13 changed the probe line to the redacted `OK upstreams[i]`
|
||||
// form; this expectation went stale unnoticed because nothing ran -Dlive
|
||||
// between then and milestone 20.
|
||||
try testing.expect(std.mem.count(
|
||||
u8,
|
||||
captured.out.written(),
|
||||
"OK https://cloudflare-dns.com/dns-query\n",
|
||||
"OK upstreams[0] https://cloudflare-dns.com\n",
|
||||
) == 1);
|
||||
}
|
||||
|
||||
+2
-1
@@ -49,7 +49,8 @@ comptime {
|
||||
_ = @import("storage/repositories/settings_repo.zig");
|
||||
_ = @import("config/export.zig");
|
||||
_ = @import("config/import.zig");
|
||||
_ = @import("config/bootstrap.zig");
|
||||
_ = @import("config/loader.zig");
|
||||
_ = @import("config/reconcile.zig");
|
||||
_ = @import("cli.zig");
|
||||
_ = @import("storage/storage_integration_test.zig");
|
||||
_ = @import("filter/parsers.zig");
|
||||
|
||||
+6
-2
@@ -58,9 +58,12 @@ pub const cookie_attributes = "HttpOnly; SameSite=Lax; Path=/";
|
||||
pub const max_password_len = 256;
|
||||
|
||||
/// Authentication is on exactly when a hash exists (ruling 17). An empty hash
|
||||
/// is the documented "no password set" state, not a misconfiguration.
|
||||
/// is the documented "no password set" state, not a misconfiguration; a null
|
||||
/// one means the settings table holds no hash row at all, which is the same
|
||||
/// answer.
|
||||
pub fn authEnabled(web: model.Web) bool {
|
||||
return web.password_hash.len != 0;
|
||||
const hash = web.password_hash orelse return false;
|
||||
return hash.len != 0;
|
||||
}
|
||||
|
||||
pub const Outcome = enum {
|
||||
@@ -453,6 +456,7 @@ fn tokenOf(n: u8) [token_bytes]u8 {
|
||||
|
||||
test "authEnabled follows the presence of a hash" {
|
||||
try testing.expect(!authEnabled(.{}));
|
||||
try testing.expect(!authEnabled(.{ .password_hash = null }));
|
||||
try testing.expect(!authEnabled(.{ .password_hash = "" }));
|
||||
try testing.expect(authEnabled(.{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" }));
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const Allocator = std.mem.Allocator;
|
||||
|
||||
const address = @import("../../platform/address.zig");
|
||||
const clients_repo = @import("../../storage/repositories/clients_repo.zig");
|
||||
const db = @import("../../storage/db.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const mutations = @import("mutations.zig");
|
||||
@@ -155,7 +156,76 @@ const resource = mutations.Resource(.{
|
||||
|
||||
pub const list = resource.list;
|
||||
pub const get = resource.get;
|
||||
pub const remove = resource.remove;
|
||||
|
||||
/// What file authority found when it went to delete a row.
|
||||
pub const ObservedDelete = enum { deleted, declared, absent };
|
||||
|
||||
/// Reads `hand_edited` and acts on it inside one `BEGIN IMMEDIATE`, because the
|
||||
/// two halves are a single decision. Split across two statements, a concurrent
|
||||
/// `nxdns import` — which takes the same write lock for its own reconcile — can
|
||||
/// promote the row between the read and the DELETE, and file authority would
|
||||
/// delete a client the file had just declared. Holding the write lock across
|
||||
/// both makes the promotion wait, and it then sees the row already gone or
|
||||
/// still there, never half of each.
|
||||
///
|
||||
/// A read-only outcome commits an empty transaction, which costs nothing and
|
||||
/// keeps the one exit path.
|
||||
fn deleteIfObserved(database: *db.Db, arena: Allocator, id: i64) db.Error!ObservedDelete {
|
||||
var tx = try db.Tx.begin(database);
|
||||
errdefer tx.rollback();
|
||||
|
||||
const row = try clients_repo.getClient(database, arena, id);
|
||||
const verdict: ObservedDelete = if (row) |found|
|
||||
(if (found.hand_edited) .declared else .deleted)
|
||||
else
|
||||
.absent;
|
||||
|
||||
if (verdict == .deleted) try clients_repo.deleteClient(database, id);
|
||||
try tx.commit();
|
||||
return verdict;
|
||||
}
|
||||
|
||||
/// DELETE is a `runtime_action` in the route table (milestone-20 ruling 7), so
|
||||
/// file authority lets it through: an observed row is runtime state the file
|
||||
/// never declared, and without a way to remove it a mis-identified or departed
|
||||
/// device would be immortal — the file can promote an IP, never forget one.
|
||||
/// A row the file *declares* is configuration, and deleting it would contradict
|
||||
/// the file, so it answers the same 403 the router answers elsewhere. This is
|
||||
/// the one policy decision that needs a row read, which is why it is here and
|
||||
/// not a table column.
|
||||
///
|
||||
/// A row that is not there is a 404, exactly as in database mode: file
|
||||
/// authority must not turn a missing row into a policy verdict.
|
||||
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const path = switch (state.authority) {
|
||||
.database => return resource.remove(state, io, request),
|
||||
.managed_file => |managed| managed,
|
||||
};
|
||||
|
||||
const database = mutations.requireConfigDb(state) catch
|
||||
return mutations.respondFailure(request, mutations.no_config_db, delete_what);
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const outcome = deleteIfObserved(database, request.arena, request.id.?);
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
switch (outcome catch |err| return mutations.respondFailure(
|
||||
request,
|
||||
mutations.dbFailure(err, group_conflict),
|
||||
delete_what,
|
||||
)) {
|
||||
.absent => return mutations.respondFailure(request, .not_found, ""),
|
||||
.declared => return http_util.respondManagedByFile(request, path),
|
||||
.deleted => {},
|
||||
}
|
||||
|
||||
if (mutations.reload(state, io)) |failure| {
|
||||
return mutations.respondFailure(request, failure, delete_what);
|
||||
}
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
}
|
||||
|
||||
const delete_what = "deleting a client";
|
||||
|
||||
/// The prefixes are one list resource with no `/{id}` route: the whole set is
|
||||
/// read and replaced (ruling 9), so there is nothing to get or delete by id.
|
||||
@@ -280,6 +350,45 @@ test "deleting a client removes the row and announces the change" {
|
||||
try testing.expectEqual(@as(usize, 1), bench.reloads);
|
||||
}
|
||||
|
||||
test "file authority deletes an observed client and refuses a declared one" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seedClient(&bench);
|
||||
try bench.exec(
|
||||
\\INSERT INTO clients (id, ip, group_id, hand_edited, first_seen, last_seen)
|
||||
\\VALUES (2, '192.168.1.11', 1, 1, 100, 200);
|
||||
);
|
||||
|
||||
// The declared row is configuration; it survives, and nothing is written.
|
||||
try testing.expectEqual(ObservedDelete.declared, try deleteIfObserved(&bench.database, bench.arena(), 2));
|
||||
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 2"));
|
||||
|
||||
try testing.expectEqual(ObservedDelete.deleted, try deleteIfObserved(&bench.database, bench.arena(), 1));
|
||||
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 1"));
|
||||
|
||||
try testing.expectEqual(ObservedDelete.absent, try deleteIfObserved(&bench.database, bench.arena(), 999));
|
||||
}
|
||||
|
||||
test "the observed check and the delete are one transaction" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seedClient(&bench);
|
||||
|
||||
// SQLite refuses a `BEGIN IMMEDIATE` inside an open transaction, so a held
|
||||
// transaction is what proves this takes the write lock rather than reading
|
||||
// and deleting through two unsynchronised statements — the window a
|
||||
// concurrent `nxdns import` would promote the row in. Without the
|
||||
// transaction both statements run and the row is gone.
|
||||
var tx = try db.Tx.begin(&bench.database);
|
||||
try testing.expectError(error.Unexpected, deleteIfObserved(&bench.database, bench.arena(), 1));
|
||||
tx.rollback();
|
||||
|
||||
// The row is untouched: the refusal happened before any statement ran.
|
||||
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 1"));
|
||||
}
|
||||
|
||||
test "the prefix list is replaced whole" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
|
||||
@@ -125,9 +125,15 @@ fn Partial(comptime Section: type, comptime section_name: []const u8) type {
|
||||
|
||||
/// Enums arrive as the words the database stores, so they are parsed from text
|
||||
/// rather than by tag name (`logging.level` is `error`, whose tag cannot be).
|
||||
///
|
||||
/// An optional model field collapses to its child, because `Partial` wraps
|
||||
/// every field in one optional of its own and that optional already carries the
|
||||
/// only meaning a PUT has for absence — "leave it". A double optional would be
|
||||
/// two ways to say the same thing, and `std.json` cannot parse the outer one.
|
||||
fn FieldType(comptime T: type) type {
|
||||
return switch (@typeInfo(T)) {
|
||||
.@"enum" => []const u8,
|
||||
.optional => |info| FieldType(info.child),
|
||||
else => T,
|
||||
};
|
||||
}
|
||||
@@ -321,16 +327,17 @@ pub fn applyPut(
|
||||
|
||||
// The password never becomes a row: the hash made above is what the merged
|
||||
// configuration — and therefore the settings table — carries.
|
||||
const previous_hash = cfg.web.password_hash;
|
||||
const previous_hash = cfg.web.password_hash orelse "";
|
||||
if (password != null) cfg.web.password_hash = new_hash;
|
||||
cfg.web.password = "";
|
||||
cfg.web.password = null;
|
||||
|
||||
if (try problem(arena, cfg)) |text| return .{ .fail = .{ .invalid = text } };
|
||||
|
||||
// The gpa copy the live holder will own, made before the write so a
|
||||
// committed transaction can never be followed by a failed revocation.
|
||||
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, cfg.web.password_hash);
|
||||
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, cfg.web.password_hash) else null;
|
||||
const merged_hash = cfg.web.password_hash orelse "";
|
||||
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, merged_hash);
|
||||
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, merged_hash) else null;
|
||||
|
||||
writeSettings(arena, database, cfg) catch |err| {
|
||||
if (replacement) |hash| state.gpa.free(hash);
|
||||
@@ -365,6 +372,12 @@ fn writeSettings(arena: Allocator, database: *db.Db, cfg: model.Config) db.Error
|
||||
for (pairs.items) |pair| {
|
||||
try settings_repo.putSetting(database, pair.key, pair.value);
|
||||
}
|
||||
// `toSettings` stops at `web.password_hash` (ruling 4 of milestone 20: the
|
||||
// reconcile engine owns that row, because only it can tell "the file said
|
||||
// nothing" from "the file said empty"). A PUT has no such ambiguity — the
|
||||
// merged configuration is the whole truth — so this handler writes the row
|
||||
// itself rather than losing the password change.
|
||||
try settings_repo.putSetting(database, "web.password_hash", cfg.web.password_hash orelse "");
|
||||
try tx.commit();
|
||||
}
|
||||
|
||||
@@ -455,6 +468,36 @@ pub const hash_stall_control = if (builtin.is_test) struct {
|
||||
// routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Which source governs this process's configuration, and when it last read
|
||||
/// it (milestone-20 ruling 7). This is how the UI learns that configuration is
|
||||
/// read-only — declaratively, rather than by probing a route for a 403.
|
||||
///
|
||||
/// It rides `GET /api/settings` because that route needs a session: the
|
||||
/// managed path is a filesystem path and must never reach the open
|
||||
/// `/api/version` or `/api/health`.
|
||||
///
|
||||
/// `reconciled_at` means exactly "this process loaded the file at T". A file
|
||||
/// whose mtime is newer has not been loaded by the running process. It cannot
|
||||
/// answer "is the file what the server uses" — a stepped clock or a preserved
|
||||
/// mtime defeats the comparison in either direction, and the database can move
|
||||
/// under `nxdns import` without either timestamp moving.
|
||||
const AuthorityView = struct {
|
||||
mode: []const u8,
|
||||
path: ?[]const u8,
|
||||
reconciled_at: ?i64,
|
||||
};
|
||||
|
||||
fn authorityView(state: *const server.WebState) AuthorityView {
|
||||
return switch (state.authority) {
|
||||
.database => .{ .mode = "database", .path = null, .reconciled_at = state.reconciled_at },
|
||||
.managed_file => |path| .{
|
||||
.mode = "managed_file",
|
||||
.path = path,
|
||||
.reconciled_at = state.reconciled_at,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const database = mutations.requireConfigDb(state) catch
|
||||
return mutations.respondFailure(request, mutations.no_config_db, "reading the settings");
|
||||
@@ -470,7 +513,7 @@ pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
|
||||
const cfg = loaded catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading the settings");
|
||||
|
||||
return respondSettings(request, .ok, cfg);
|
||||
return respondSettings(request, state, .ok, cfg);
|
||||
}
|
||||
|
||||
pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
@@ -479,14 +522,20 @@ pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
|
||||
|
||||
return switch (try applyPut(state, io, request.arena, parsed.value)) {
|
||||
.fail => |failure| mutations.respondFailure(request, failure, "writing the settings"),
|
||||
.config => |cfg| respondSettings(request, .ok, cfg),
|
||||
.config => |cfg| respondSettings(request, state, .ok, cfg),
|
||||
};
|
||||
}
|
||||
|
||||
fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config) HandlerError!void {
|
||||
fn respondSettings(
|
||||
request: *Request,
|
||||
state: *const server.WebState,
|
||||
status: std.http.Status,
|
||||
cfg: model.Config,
|
||||
) HandlerError!void {
|
||||
return http_util.respondJson(request, status, .{
|
||||
.settings = view(cfg),
|
||||
.restart_required = restart_required_keys,
|
||||
.authority = authorityView(state),
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
@@ -498,8 +547,10 @@ const testing = std.testing;
|
||||
const auth_handlers = @import("auth.zig");
|
||||
|
||||
test "the restart-required table lists every settings key and no secret" {
|
||||
// `model.toSettings` is the other half of the same fact: the keys the
|
||||
// database stores, minus the hash the API never serializes.
|
||||
// `model.toSettings` is the other half of the same fact. The two lists are
|
||||
// now equal rather than off by one: `toSettings` stopped emitting
|
||||
// `web.password_hash` (milestone 20 ruling 4) and this table never listed
|
||||
// it, so both exclude the hash and the plaintext.
|
||||
var pairs: std.ArrayList(model.SettingPair) = .empty;
|
||||
defer {
|
||||
model.freeSettings(testing.allocator, pairs.items);
|
||||
@@ -507,7 +558,7 @@ test "the restart-required table lists every settings key and no secret" {
|
||||
}
|
||||
try model.toSettings(.{}, testing.allocator, &pairs);
|
||||
|
||||
try testing.expectEqual(pairs.items.len - 1, restart_required_keys.len);
|
||||
try testing.expectEqual(pairs.items.len, restart_required_keys.len);
|
||||
for (restart_required_keys) |key| {
|
||||
try testing.expect(!std.mem.eql(u8, key, "web.password_hash"));
|
||||
try testing.expect(!std.mem.eql(u8, key, "web.password"));
|
||||
@@ -641,7 +692,7 @@ test "a new password is stored as a hash and ends every session" {
|
||||
patch.web = .{ .password = "correct horse battery staple" };
|
||||
|
||||
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
|
||||
try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash, "$argon2id$"));
|
||||
try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash.?, "$argon2id$"));
|
||||
try testing.expect(!sessions.validateAt(bench.io(), &cookie, 1_001));
|
||||
|
||||
// The plain password is nowhere in the table, and the hash is.
|
||||
@@ -650,10 +701,10 @@ test "a new password is stored as a hash and ends every session" {
|
||||
try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.password'"),
|
||||
);
|
||||
const stored = try mutations.loadConfig(bench.arena(), &bench.database);
|
||||
try testing.expect(std.mem.startsWith(u8, stored.web.password_hash, "$argon2id$"));
|
||||
try testing.expect(std.mem.startsWith(u8, stored.web.password_hash.?, "$argon2id$"));
|
||||
try testing.expectEqual(
|
||||
auth.Outcome.ok,
|
||||
try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash, "correct horse battery staple"),
|
||||
try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash.?, "correct horse battery staple"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -713,7 +764,7 @@ test "an empty password is not a password change" {
|
||||
patch.web = .{ .password = "" };
|
||||
|
||||
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
|
||||
try testing.expectEqualStrings("", outcome.config.web.password_hash);
|
||||
try testing.expectEqualStrings("", outcome.config.web.password_hash orelse "");
|
||||
}
|
||||
|
||||
test "reading the settings with no database is unavailable" {
|
||||
|
||||
+25
-10
@@ -307,23 +307,38 @@ pub fn parseBody(comptime T: type, request: *Request) (BodyError || error{BadJso
|
||||
|
||||
/// Ruling 8's envelope. `message` is operator-facing text, never a raw internal
|
||||
/// error string for a 500 (PLAN §19: details go to the log, not the wire).
|
||||
///
|
||||
/// Built on the request arena, like `respondJson` below. It used to build into
|
||||
/// a 512-byte stack buffer and fall back to `text/plain` when the message
|
||||
/// overflowed it, which made the documented JSON envelope a function of message
|
||||
/// length — a long managed-file path (milestone-20 ruling 7) was enough to
|
||||
/// demote it. The envelope is `application/json` at every length now.
|
||||
pub fn respondError(
|
||||
request: *Request,
|
||||
status: http.Status,
|
||||
message: []const u8,
|
||||
) HandlerError!void {
|
||||
var buf: [512]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
var stringify: std.json.Stringify = .{ .writer = &writer };
|
||||
stringify.beginObject() catch return respondPlain(request, status, message);
|
||||
stringify.objectField("error") catch return respondPlain(request, status, message);
|
||||
stringify.write(message) catch return respondPlain(request, status, message);
|
||||
stringify.endObject() catch return respondPlain(request, status, message);
|
||||
return respondBytes(request, status, writer.buffered(), content_type_json, &.{});
|
||||
var allocating: std.Io.Writer.Allocating = .init(request.arena);
|
||||
defer allocating.deinit();
|
||||
var stringify: std.json.Stringify = .{ .writer = &allocating.writer };
|
||||
stringify.beginObject() catch return error.OutOfMemory;
|
||||
stringify.objectField("error") catch return error.OutOfMemory;
|
||||
stringify.write(message) catch return error.OutOfMemory;
|
||||
stringify.endObject() catch return error.OutOfMemory;
|
||||
return respondBytes(request, status, allocating.written(), content_type_json, &.{});
|
||||
}
|
||||
|
||||
fn respondPlain(request: *Request, status: http.Status, message: []const u8) HandlerError!void {
|
||||
return respondBytes(request, status, message, content_type_text, &.{});
|
||||
/// Milestone-20 ruling 7's rejection: a configuration write under file
|
||||
/// authority. One function, because the router rejects most of them and the
|
||||
/// clients handler rejects the one that needs a row read — two wordings would
|
||||
/// be two contracts.
|
||||
pub fn respondManagedByFile(request: *Request, path: []const u8) HandlerError!void {
|
||||
const message = try std.fmt.allocPrint(
|
||||
request.arena,
|
||||
"configuration is managed by {s}; edit the file and restart",
|
||||
.{path},
|
||||
);
|
||||
return respondError(request, .forbidden, message);
|
||||
}
|
||||
|
||||
/// Serialises `value` and responds. The document is built in the request arena
|
||||
|
||||
+99
-1
@@ -29,6 +29,15 @@ info:
|
||||
- Mutations to groups, blocklists, rules, local records, forward zones,
|
||||
clients and client prefixes take effect live. Upstreams and
|
||||
`/api/settings` are restart-required.
|
||||
- nxdns runs under one of two configuration authorities. Started with
|
||||
`--config=<file>`, that file is the sole declarative source, and every
|
||||
operation that writes configuration answers 403 with the same error
|
||||
envelope, naming the file. Operations that change runtime state —
|
||||
`/api/pause`, `POST /api/blocklists/update`, `/api/certs/reload`, the
|
||||
login and the logout — stay live, as does `DELETE /api/clients/{id}`
|
||||
for a client the file does not declare. `GET /api/settings` reports
|
||||
the live authority, so a client reads the mode rather than
|
||||
discovering it from a rejection.
|
||||
|
||||
servers:
|
||||
- url: /
|
||||
@@ -391,6 +400,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"413":
|
||||
@@ -444,6 +455,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -466,6 +479,8 @@ paths:
|
||||
description: Deleted; applied live.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -519,6 +534,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -575,6 +592,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"413":
|
||||
@@ -655,6 +674,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -674,6 +695,8 @@ paths:
|
||||
description: Deleted; applied live.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -728,6 +751,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"413":
|
||||
@@ -780,6 +805,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -799,6 +826,8 @@ paths:
|
||||
description: Deleted; applied live.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -853,6 +882,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"413":
|
||||
@@ -905,6 +936,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -924,6 +957,8 @@ paths:
|
||||
description: Deleted; applied live.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -978,6 +1013,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"413":
|
||||
@@ -1030,6 +1067,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -1049,6 +1088,8 @@ paths:
|
||||
description: Deleted; applied live.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -1133,6 +1174,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -1222,6 +1265,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"413":
|
||||
@@ -1277,6 +1322,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"413":
|
||||
@@ -1330,6 +1377,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -1350,6 +1399,8 @@ paths:
|
||||
description: Deleted; takes effect on restart.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
@@ -1454,6 +1505,8 @@ paths:
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagedByFile"
|
||||
"413":
|
||||
$ref: "#/components/responses/BodyTooLarge"
|
||||
"429":
|
||||
@@ -1526,6 +1579,16 @@ components:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
ManagedByFile:
|
||||
description: |
|
||||
nxdns is running under file authority and this operation writes
|
||||
configuration. The message names the file. Authentication is checked
|
||||
first, so an unauthenticated request to a protected route still
|
||||
answers 401 rather than disclosing that the route exists.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
NotFound:
|
||||
description: No row has this id.
|
||||
content:
|
||||
@@ -2193,7 +2256,7 @@ components:
|
||||
|
||||
SettingsEnvelope:
|
||||
type: object
|
||||
required: [settings, restart_required]
|
||||
required: [settings, restart_required, authority]
|
||||
properties:
|
||||
settings:
|
||||
$ref: "#/components/schemas/Settings"
|
||||
@@ -2203,6 +2266,41 @@ components:
|
||||
description: |
|
||||
Every `section.field` key that needs a restart to take effect —
|
||||
currently all of them.
|
||||
authority:
|
||||
$ref: "#/components/schemas/Authority"
|
||||
|
||||
Authority:
|
||||
type: object
|
||||
description: |
|
||||
Which source governs this process's configuration. This is how a
|
||||
client learns that configuration is read-only; it never has to probe
|
||||
a write route for a 403. The block rides this authenticated endpoint
|
||||
because `path` is a filesystem path, and never appears on the open
|
||||
`/api/version` or `/api/health`.
|
||||
required: [mode, path, reconciled_at]
|
||||
properties:
|
||||
mode:
|
||||
type: string
|
||||
enum: [database, managed_file]
|
||||
description: |
|
||||
`database` when nxdns runs without `--config`; `managed_file`
|
||||
when it runs with it, in which case every configuration write
|
||||
answers 403.
|
||||
path:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The managed file, or null in `database` mode.
|
||||
reconciled_at:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: |
|
||||
When this process loaded the managed file, in epoch seconds, and
|
||||
null in `database` mode. It means exactly that: a file whose
|
||||
mtime is newer has not been loaded by the running process. It
|
||||
cannot answer whether the file matches what the server serves —
|
||||
a stepped clock or a preserved mtime defeats the comparison
|
||||
either way, and `nxdns import` can move the database without
|
||||
moving either timestamp.
|
||||
|
||||
SettingsPatch:
|
||||
type: object
|
||||
|
||||
@@ -48,6 +48,84 @@ test "every served route appears textually in the document" {
|
||||
}
|
||||
}
|
||||
|
||||
// Drift guard for milestone-20 ruling 7: a route classified `config_write` can
|
||||
// answer 403 under file authority, so its operation must say so — and a route
|
||||
// that cannot must not claim it. Textual, like the coverage test above: the
|
||||
// document has no parser here, and the two facts it compares are one line each.
|
||||
test "every config write documents the file-authority 403, and nothing else does" {
|
||||
for (router.routes) |route| {
|
||||
const operation = try operationBlock(route.pattern, route.method);
|
||||
const documented = std.mem.containsAtLeast(u8, operation, 1, "\n \"403\":\n");
|
||||
if (documented != (route.policy == .config_write)) {
|
||||
std.debug.print(
|
||||
"{t} {s} is {t} but {s} a 403\n",
|
||||
.{ route.method, route.pattern, route.policy, if (documented) "documents" else "does not document" },
|
||||
);
|
||||
return error.TestUnexpectedResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The body of one operation: everything under `pattern`'s `method` key.
|
||||
///
|
||||
/// The path block is bounded *before* the method is looked for. Searching the
|
||||
/// rest of the document instead would let a later path's `delete:` answer for a
|
||||
/// path that has none, and the guard above would pass on an operation nobody
|
||||
/// documented.
|
||||
fn operationBlock(pattern: []const u8, method: std.http.Method) ![]const u8 {
|
||||
var key_buf: [128]u8 = undefined;
|
||||
const path_key = try std.fmt.bufPrint(&key_buf, "\n {s}:\n", .{pattern});
|
||||
const path_at = std.mem.indexOf(u8, yaml, path_key) orelse return error.PathNotDocumented;
|
||||
const path_body = blockUnder(yaml[path_at + path_key.len ..], 2);
|
||||
|
||||
var method_buf: [16]u8 = undefined;
|
||||
const method_key = try std.fmt.bufPrint(&method_buf, " {s}:\n", .{@tagName(method)});
|
||||
_ = std.ascii.lowerString(&method_buf, method_key);
|
||||
const key = method_buf[0..method_key.len];
|
||||
|
||||
// Anchored at a line start: a `get:` nested deeper inside a description
|
||||
// contains the four-space key as a substring.
|
||||
var offset: usize = 0;
|
||||
while (offset < path_body.len) {
|
||||
if (std.mem.startsWith(u8, path_body[offset..], key)) {
|
||||
return blockUnder(path_body[offset + key.len ..], 4);
|
||||
}
|
||||
offset = (std.mem.indexOfScalarPos(u8, path_body, offset, '\n') orelse path_body.len) + 1;
|
||||
}
|
||||
return error.MethodNotDocumented;
|
||||
}
|
||||
|
||||
/// The run of lines at the start of `body` indented deeper than `indent` — what
|
||||
/// belongs to the key that just ended. `body` starts at a line boundary. Blank
|
||||
/// lines belong to whatever surrounds them and never close a block.
|
||||
fn blockUnder(body: []const u8, indent: usize) []const u8 {
|
||||
var offset: usize = 0;
|
||||
while (offset < body.len) {
|
||||
const line_end = std.mem.indexOfScalarPos(u8, body, offset, '\n') orelse body.len;
|
||||
if (line_end != offset) {
|
||||
const depth = for (body[offset..line_end], 0..) |c, i| {
|
||||
if (c != ' ') break i;
|
||||
} else line_end - offset;
|
||||
if (depth <= indent) return body[0..offset];
|
||||
}
|
||||
offset = line_end + 1;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
test "an operation block stops at its own path and its own method" {
|
||||
// `/api/groups` has no DELETE. An unbounded search answers with the one
|
||||
// under `/api/groups/{id}`, and the 403 guard then grades the wrong
|
||||
// operation — silently passing for a route nobody documented.
|
||||
try testing.expectError(error.MethodNotDocumented, operationBlock("/api/groups", .DELETE));
|
||||
|
||||
// A block it does have never reaches into its neighbour under the same
|
||||
// path either.
|
||||
const list_groups = try operationBlock("/api/groups", .GET);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, list_groups, 1, "List groups"));
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, list_groups, 1, "Create a group"));
|
||||
}
|
||||
|
||||
test "the document names the contract's fixed points" {
|
||||
for ([_][]const u8{
|
||||
"openapi: 3.0.3",
|
||||
|
||||
+55
-7
@@ -37,12 +37,20 @@ pub const Auth = enum { open, session };
|
||||
/// monitoring endpoints so a Prometheus scrape can never be throttled.
|
||||
pub const RateLimit = enum { counted, exempt };
|
||||
|
||||
/// What a route does to the configuration, and therefore whether file
|
||||
/// authority may allow it (milestone-20 ruling 7). `config_write` changes the
|
||||
/// declarative state the managed file owns; `runtime_action` changes runtime
|
||||
/// state the file never declares; `read` changes nothing.
|
||||
pub const Policy = enum { read, config_write, runtime_action };
|
||||
|
||||
pub const RouteInfo = struct {
|
||||
method: http.Method,
|
||||
/// Segments separated by `/`, with at most one `{id}` capture, which must
|
||||
/// be a positive integer row id.
|
||||
pattern: []const u8,
|
||||
auth: Auth,
|
||||
/// No default: a new route states its class or does not compile.
|
||||
policy: Policy,
|
||||
handler: HandlerFn,
|
||||
rate_limit: RateLimit = .counted,
|
||||
};
|
||||
@@ -158,6 +166,17 @@ pub fn dispatch(
|
||||
return http_util.respondError(request, .unauthorized, "authentication required");
|
||||
}
|
||||
|
||||
// Milestone-20 ruling 7, and it runs *after* the auth check on purpose:
|
||||
// rejecting before authenticating would tell an anonymous caller which
|
||||
// routes exist. An unauthenticated request to a protected route answers
|
||||
// 401 in both authority modes.
|
||||
if (found.route.policy == .config_write) {
|
||||
switch (state.authority) {
|
||||
.database => {},
|
||||
.managed_file => |path| return http_util.respondManagedByFile(request, path),
|
||||
}
|
||||
}
|
||||
|
||||
return found.route.handler(state, io, request);
|
||||
}
|
||||
|
||||
@@ -196,13 +215,14 @@ fn noopHandler(
|
||||
}
|
||||
|
||||
const test_table = [_]RouteInfo{
|
||||
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = noopHandler, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = noopHandler },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = noopHandler },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
|
||||
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = noopHandler },
|
||||
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = noopHandler, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = noopHandler },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = noopHandler },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = noopHandler },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
|
||||
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .handler = noopHandler },
|
||||
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .handler = noopHandler },
|
||||
};
|
||||
|
||||
fn matchPath(method: http.Method, path: []const u8) Match {
|
||||
@@ -263,6 +283,34 @@ test "the allow header lists every method the path accepts" {
|
||||
try testing.expectEqualStrings("GET, PUT, DELETE", formatAllow(&test_table, item.segments(), &buf));
|
||||
}
|
||||
|
||||
test "matching carries the class the table declares, per route and not per prefix" {
|
||||
const cases = [_]struct { method: http.Method, path: []const u8, policy: Policy }{
|
||||
.{ .method = .GET, .path = "/api/groups", .policy = .read },
|
||||
.{ .method = .GET, .path = "/api/groups/7", .policy = .read },
|
||||
.{ .method = .POST, .path = "/api/groups", .policy = .config_write },
|
||||
.{ .method = .PUT, .path = "/api/groups/7", .policy = .config_write },
|
||||
.{ .method = .DELETE, .path = "/api/groups/7", .policy = .config_write },
|
||||
.{ .method = .PUT, .path = "/api/groups/7/sources", .policy = .config_write },
|
||||
// Same prefix, different class: the column is per route.
|
||||
.{ .method = .POST, .path = "/api/pause", .policy = .runtime_action },
|
||||
};
|
||||
for (cases) |case| {
|
||||
try testing.expectEqual(case.policy, matchPath(case.method, case.path).found.route.policy);
|
||||
}
|
||||
}
|
||||
|
||||
test "the shipped route table classifies /api/blocklists by route, not by prefix" {
|
||||
var refresh: ?Policy = null;
|
||||
var create: ?Policy = null;
|
||||
for (routes) |route| {
|
||||
if (route.method != .POST) continue;
|
||||
if (std.mem.eql(u8, route.pattern, "/api/blocklists/update")) refresh = route.policy;
|
||||
if (std.mem.eql(u8, route.pattern, "/api/blocklists")) create = route.policy;
|
||||
}
|
||||
try testing.expectEqual(Policy.runtime_action, refresh.?);
|
||||
try testing.expectEqual(Policy.config_write, create.?);
|
||||
}
|
||||
|
||||
test "the shipped route table is the one the router matches against" {
|
||||
try testing.expectEqual(routes_table.table.ptr, routes.ptr);
|
||||
try testing.expectEqual(routes_table.table.len, routes.len);
|
||||
|
||||
+132
-56
@@ -18,6 +18,17 @@
|
||||
//! bucket. The static assets are ruling 18's remaining exemption; they are
|
||||
//! not routes — the router sends unmatched non-`/api` paths to
|
||||
//! `WebState.fallback` before any policy check.
|
||||
//!
|
||||
//! `policy` is the third such column, and milestone-20 ruling 7's contract:
|
||||
//! under file authority the file is the sole declarative source, so a
|
||||
//! `config_write` answers 403 and a `runtime_action` stays live. It has no
|
||||
//! default value on purpose — a route added without a stated class must not
|
||||
//! inherit one. Classification is per route, not per prefix:
|
||||
//! `POST /api/blocklists/update` is a refresh, a `runtime_action`, while its
|
||||
//! CRUD siblings write configuration. `DELETE /api/clients/{id}` is a
|
||||
//! `runtime_action` here because deleting an *observed* row discards runtime
|
||||
//! state the file never declared; the declared case needs a row read and the
|
||||
//! clients handler answers it.
|
||||
|
||||
const router = @import("router.zig");
|
||||
|
||||
@@ -43,85 +54,85 @@ const version = @import("handlers/version.zig");
|
||||
|
||||
pub const table: []const router.RouteInfo = &.{
|
||||
// Monitoring and contract (ruling 18's open set, ruling 19's exemptions).
|
||||
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .handler = metrics.handle, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = health.handle, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .handler = version.handle },
|
||||
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .handler = openapi.handle },
|
||||
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .policy = .read, .handler = metrics.handle, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = health.handle, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .policy = .read, .handler = version.handle },
|
||||
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .policy = .read, .handler = openapi.handle },
|
||||
|
||||
// Authentication.
|
||||
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .handler = auth.login },
|
||||
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .handler = auth.logout },
|
||||
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .policy = .runtime_action, .handler = auth.login },
|
||||
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .policy = .runtime_action, .handler = auth.logout },
|
||||
|
||||
// Query log, stats, live stream, lookup.
|
||||
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .handler = queries.list },
|
||||
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .handler = live.stream, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .handler = stats.totals },
|
||||
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .handler = stats.timeseries },
|
||||
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .handler = lookup.handle },
|
||||
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .handler = upstream_health.handle },
|
||||
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .handler = queries.list },
|
||||
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .handler = live.stream, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .handler = stats.totals },
|
||||
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .handler = stats.timeseries },
|
||||
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .handler = lookup.handle },
|
||||
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .handler = upstream_health.handle },
|
||||
|
||||
// Groups.
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = groups.list },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = groups.create },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.get },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.remove },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.getSources },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.putSources },
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = groups.list },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = groups.create },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = groups.get },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = groups.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = groups.remove },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .read, .handler = groups.getSources },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .handler = groups.putSources },
|
||||
|
||||
// Blocklist sources. `/api/blocklists/update` is a literal segment; it
|
||||
// cannot collide with `{id}`, which only matches a positive integer.
|
||||
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.list },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.create },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .handler = blocklists.refresh },
|
||||
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.get },
|
||||
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.remove },
|
||||
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .policy = .read, .handler = blocklists.list },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .policy = .config_write, .handler = blocklists.create },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .policy = .runtime_action, .handler = blocklists.refresh },
|
||||
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .read, .handler = blocklists.get },
|
||||
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .handler = blocklists.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .handler = blocklists.remove },
|
||||
|
||||
// Rules.
|
||||
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .handler = rules.list },
|
||||
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .handler = rules.create },
|
||||
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.get },
|
||||
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.remove },
|
||||
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .policy = .read, .handler = rules.list },
|
||||
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .policy = .config_write, .handler = rules.create },
|
||||
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .policy = .read, .handler = rules.get },
|
||||
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .handler = rules.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .handler = rules.remove },
|
||||
|
||||
// Local records.
|
||||
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .handler = local.listRecords },
|
||||
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .handler = local.createRecord },
|
||||
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.getRecord },
|
||||
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.updateRecord },
|
||||
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.removeRecord },
|
||||
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .policy = .read, .handler = local.listRecords },
|
||||
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .policy = .config_write, .handler = local.createRecord },
|
||||
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .read, .handler = local.getRecord },
|
||||
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .handler = local.updateRecord },
|
||||
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .handler = local.removeRecord },
|
||||
|
||||
// Forward zones.
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .handler = local.listZones },
|
||||
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .handler = local.createZone },
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.getZone },
|
||||
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.updateZone },
|
||||
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.removeZone },
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .policy = .read, .handler = local.listZones },
|
||||
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .policy = .config_write, .handler = local.createZone },
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .read, .handler = local.getZone },
|
||||
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .handler = local.updateZone },
|
||||
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .handler = local.removeZone },
|
||||
|
||||
// Clients (no POST — rows come from DNS activity or import, ruling 9).
|
||||
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .handler = clients.list },
|
||||
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.get },
|
||||
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.remove },
|
||||
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.listPrefixes },
|
||||
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.putPrefixes },
|
||||
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .policy = .read, .handler = clients.list },
|
||||
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .policy = .read, .handler = clients.get },
|
||||
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .policy = .config_write, .handler = clients.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .policy = .runtime_action, .handler = clients.remove },
|
||||
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .policy = .read, .handler = clients.listPrefixes },
|
||||
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .policy = .config_write, .handler = clients.putPrefixes },
|
||||
|
||||
// Upstreams (restart-required resource).
|
||||
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.list },
|
||||
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.create },
|
||||
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.get },
|
||||
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.remove },
|
||||
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .policy = .read, .handler = upstreams.list },
|
||||
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .policy = .config_write, .handler = upstreams.create },
|
||||
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .read, .handler = upstreams.get },
|
||||
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .handler = upstreams.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .handler = upstreams.remove },
|
||||
|
||||
// Pause and settings.
|
||||
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .handler = pause.get },
|
||||
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .handler = pause.post },
|
||||
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .handler = settings.get },
|
||||
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .handler = settings.put },
|
||||
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .policy = .read, .handler = pause.get },
|
||||
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .handler = pause.post },
|
||||
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .policy = .read, .handler = settings.get },
|
||||
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .policy = .config_write, .handler = settings.put },
|
||||
|
||||
// Certificates (milestone-10 ruling 8).
|
||||
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .handler = certs.post },
|
||||
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .handler = certs.post },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -187,6 +198,71 @@ test "the limiter exemptions are the monitoring endpoints and the live stream" {
|
||||
try testing.expectEqual(exempt.len, found);
|
||||
}
|
||||
|
||||
test "the config writes are exactly the declarative mutations" {
|
||||
const writes = [_][]const u8{
|
||||
"POST /api/groups",
|
||||
"PUT /api/groups/{id}",
|
||||
"DELETE /api/groups/{id}",
|
||||
"PUT /api/groups/{id}/sources",
|
||||
"POST /api/blocklists",
|
||||
"PUT /api/blocklists/{id}",
|
||||
"DELETE /api/blocklists/{id}",
|
||||
"POST /api/rules",
|
||||
"PUT /api/rules/{id}",
|
||||
"DELETE /api/rules/{id}",
|
||||
"POST /api/local-records",
|
||||
"PUT /api/local-records/{id}",
|
||||
"DELETE /api/local-records/{id}",
|
||||
"POST /api/forward-zones",
|
||||
"PUT /api/forward-zones/{id}",
|
||||
"DELETE /api/forward-zones/{id}",
|
||||
"PUT /api/clients/{id}",
|
||||
"PUT /api/client-prefixes",
|
||||
"POST /api/upstreams",
|
||||
"PUT /api/upstreams/{id}",
|
||||
"DELETE /api/upstreams/{id}",
|
||||
"PUT /api/settings",
|
||||
};
|
||||
try expectClass(.config_write, &writes);
|
||||
}
|
||||
|
||||
test "the runtime actions are exactly ruling 7's list" {
|
||||
const actions = [_][]const u8{
|
||||
"POST /api/auth/login",
|
||||
"POST /api/auth/logout",
|
||||
"POST /api/blocklists/update",
|
||||
"DELETE /api/clients/{id}",
|
||||
"POST /api/pause",
|
||||
"POST /api/certs/reload",
|
||||
};
|
||||
try expectClass(.runtime_action, &actions);
|
||||
}
|
||||
|
||||
test "every read is a GET and every GET is a read" {
|
||||
for (table) |route| {
|
||||
try testing.expectEqual(route.method == .GET, route.policy == .read);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asserts that the routes classified `policy` are exactly `expected`, each
|
||||
/// written `METHOD /pattern`.
|
||||
fn expectClass(policy: router.Policy, expected: []const []const u8) !void {
|
||||
var buf: [64]u8 = undefined;
|
||||
var found: usize = 0;
|
||||
for (table) |route| {
|
||||
if (route.policy != policy) continue;
|
||||
found += 1;
|
||||
const label = try std.fmt.bufPrint(&buf, "{t} {s}", .{ route.method, route.pattern });
|
||||
var listed = false;
|
||||
for (expected) |name| listed = listed or std.mem.eql(u8, label, name);
|
||||
if (!listed) {
|
||||
std.debug.print("{s} is {t}, and the list does not say so\n", .{ label, policy });
|
||||
return error.TestUnexpectedResult;
|
||||
}
|
||||
}
|
||||
try testing.expectEqual(expected.len, found);
|
||||
}
|
||||
|
||||
test "item routes capture one id and collection routes capture none" {
|
||||
for (table) |route| {
|
||||
const captures = std.mem.count(u8, route.pattern, "{id}");
|
||||
|
||||
@@ -102,10 +102,31 @@ pub const ReloadFn = *const fn (state: *WebState, io: std.Io) anyerror!void;
|
||||
/// false` means several of them are never opened at all (ruling 6). A handler
|
||||
/// that finds the collaborator it needs missing answers 503, the same way it
|
||||
/// answers a missing snapshot.
|
||||
/// Which of the two sources governs this process's configuration (milestone-20
|
||||
/// ruling 1). Per-process state, never persisted: authority lives in the
|
||||
/// invocation, and the database carries no record of who wrote it.
|
||||
///
|
||||
/// The `managed_file` path is owned by `serve`'s arena, which outlives every
|
||||
/// `WebState`, so nothing here copies it.
|
||||
pub const Authority = union(enum) {
|
||||
database,
|
||||
managed_file: []const u8,
|
||||
};
|
||||
|
||||
pub const WebState = struct {
|
||||
gpa: Allocator,
|
||||
web: model.Web = .{},
|
||||
|
||||
/// Defaults to `.database`: a `WebState` nobody told about a managed file
|
||||
/// governs nothing declaratively, which is the safe reading — the mutation
|
||||
/// routes stay live rather than a half-wired server refusing every write.
|
||||
authority: Authority = .database,
|
||||
/// When this process loaded the managed file, in epoch seconds. Null in
|
||||
/// database mode, which never reconciles. It answers exactly "this process
|
||||
/// loaded the file at T" and nothing more: a file whose mtime is newer has
|
||||
/// not been loaded by the running process.
|
||||
reconciled_at: ?i64 = null,
|
||||
|
||||
handler: ?*dns_handler.Handler = null,
|
||||
pause: ?*pause_mod.Pause = null,
|
||||
tracker: ?*clients.Tracker = null,
|
||||
|
||||
@@ -90,11 +90,11 @@ fn bodyThenPathHandler(
|
||||
}
|
||||
|
||||
const test_routes = [_]router.RouteInfo{
|
||||
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = okHandler, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = okHandler },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = echoLengthHandler },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = bodyThenPathHandler },
|
||||
.{ .method = .GET, .pattern = "/api/lookup", .auth = .open, .handler = echoDomainHandler },
|
||||
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = okHandler, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = okHandler },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = echoLengthHandler },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = bodyThenPathHandler },
|
||||
.{ .method = .GET, .pattern = "/api/lookup", .auth = .open, .policy = .read, .handler = echoDomainHandler },
|
||||
};
|
||||
|
||||
fn denyAll(state: *server.WebState, io: std.Io, request: *const http_util.Request) bool {
|
||||
|
||||
@@ -264,6 +264,10 @@ const EnvOptions = struct {
|
||||
sse_max_per_ip: u16 = 3,
|
||||
trusted_proxies: []const u8 = "",
|
||||
fallback: ?router.HandlerFn = null,
|
||||
/// Milestone-20 ruling 7. `.database` is what every pre-existing test
|
||||
/// wants; the file-authority tests below name a path.
|
||||
authority: server.Authority = .database,
|
||||
reconciled_at: ?i64 = null,
|
||||
};
|
||||
|
||||
/// Heap-allocated because `state` and the listener hold pointers into it.
|
||||
@@ -372,6 +376,8 @@ const Env = struct {
|
||||
.sse_max_connections_per_ip = options.sse_max_per_ip,
|
||||
.trusted_proxies = options.trusted_proxies,
|
||||
},
|
||||
.authority = options.authority,
|
||||
.reconciled_at = options.reconciled_at,
|
||||
.live_hash = .init(options.password_hash),
|
||||
.pause = &self.pauser,
|
||||
.manager = &self.mgr,
|
||||
@@ -573,6 +579,7 @@ const SettingsView = struct {
|
||||
blocklist_update: struct { enabled: bool, interval_hours: u16 },
|
||||
},
|
||||
restart_required: []const []const u8,
|
||||
authority: struct { mode: []const u8, path: ?[]const u8, reconciled_at: ?i64 },
|
||||
};
|
||||
|
||||
const Contract = struct {
|
||||
@@ -580,6 +587,9 @@ const Contract = struct {
|
||||
/// Must equal a `routes.zig` pattern; the coverage test enforces it.
|
||||
pattern: []const u8,
|
||||
auth: router.Auth,
|
||||
/// Milestone-20 ruling 7's class, restated here so the coverage test can
|
||||
/// hold the served table to it. No default, like the route table.
|
||||
policy: router.Policy,
|
||||
rate_limit: router.RateLimit = .counted,
|
||||
/// The concrete request target the walk sends.
|
||||
target: []const u8,
|
||||
@@ -597,96 +607,96 @@ const Contract = struct {
|
||||
/// create that made the row, and deletes come last for their resource.
|
||||
const contract = [_]Contract{
|
||||
// Monitoring and contract.
|
||||
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .rate_limit = .exempt, .target = "/metrics", .status = 200, .kind = .raw, .needle = "nxdns_up 1" },
|
||||
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .rate_limit = .exempt, .target = "/api/health", .status = 200, .check = jsonShape(handlers_health.Body) },
|
||||
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .target = "/api/version", .status = 200, .check = jsonShape(handlers_version.Body) },
|
||||
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .target = "/api/openapi.yaml", .status = 200, .kind = .raw, .needle = "openapi: 3.0.3" },
|
||||
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .policy = .read, .rate_limit = .exempt, .target = "/metrics", .status = 200, .kind = .raw, .needle = "nxdns_up 1" },
|
||||
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .rate_limit = .exempt, .target = "/api/health", .status = 200, .check = jsonShape(handlers_health.Body) },
|
||||
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .policy = .read, .target = "/api/version", .status = 200, .check = jsonShape(handlers_version.Body) },
|
||||
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .policy = .read, .target = "/api/openapi.yaml", .status = 200, .kind = .raw, .needle = "openapi: 3.0.3" },
|
||||
|
||||
// Authentication (auth is disabled in the walk's environment; the on/off
|
||||
// matrix has its own test).
|
||||
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200, .check = jsonShape(LoginView) },
|
||||
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .target = "/api/auth/logout", .status = 200, .check = jsonShape(LogoutView) },
|
||||
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .policy = .runtime_action, .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200, .check = jsonShape(LoginView) },
|
||||
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .policy = .runtime_action, .target = "/api/auth/logout", .status = 200, .check = jsonShape(LogoutView) },
|
||||
|
||||
// Refresh-all before any source row exists: nothing to fetch, 202 anyway.
|
||||
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .target = "/api/blocklists/update", .status = 202, .check = jsonShape(StatusList) },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .policy = .runtime_action, .target = "/api/blocklists/update", .status = 202, .check = jsonShape(StatusList) },
|
||||
|
||||
// Query log, stats, live stream, upstream health.
|
||||
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .target = "/api/queries?limit=10", .status = 200, .check = jsonShape(handlers_queries.Page) },
|
||||
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse },
|
||||
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) },
|
||||
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) },
|
||||
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .target = "/api/upstream/health", .status = 200, .check = jsonShape(handlers_upstream_health.Body) },
|
||||
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .target = "/api/queries?limit=10", .status = 200, .check = jsonShape(handlers_queries.Page) },
|
||||
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse },
|
||||
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) },
|
||||
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) },
|
||||
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .target = "/api/upstream/health", .status = 200, .check = jsonShape(handlers_upstream_health.Body) },
|
||||
|
||||
// Groups. The migrated schema seeds `default` as id 1; the POST creates
|
||||
// id 2, which the delete at the end of the walk removes.
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .target = "/api/groups", .status = 200, .check = jsonShape(GroupsList) },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201, .check = jsonShape(GroupEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .status = 200, .check = jsonShape(groups_repo.GroupRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200, .check = jsonShape(GroupEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .target = "/api/groups/1/sources", .status = 200, .check = jsonShape(SourceIds) },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .target = "/api/groups/1/sources", .body = "{\"source_ids\":[]}", .status = 200, .check = jsonShape(SourceIds) },
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .target = "/api/groups", .status = 200, .check = jsonShape(GroupsList) },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201, .check = jsonShape(GroupEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .target = "/api/groups/2", .status = 200, .check = jsonShape(groups_repo.GroupRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200, .check = jsonShape(GroupEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .read, .target = "/api/groups/1/sources", .status = 200, .check = jsonShape(SourceIds) },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .target = "/api/groups/1/sources", .body = "{\"source_ids\":[]}", .status = 200, .check = jsonShape(SourceIds) },
|
||||
|
||||
// Blocklist sources. The POST runs after the refresh above, so the created
|
||||
// row's url is never fetched.
|
||||
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .target = "/api/blocklists", .status = 200, .check = jsonShape(SourcesList) },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .target = "/api/blocklists", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads\"}", .status = 201, .check = jsonShape(SourceEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .status = 200, .check = jsonShape(sources_repo.SourceRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200, .check = jsonShape(SourceEcho) },
|
||||
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .status = 204, .kind = .none },
|
||||
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .policy = .read, .target = "/api/blocklists", .status = 200, .check = jsonShape(SourcesList) },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .policy = .config_write, .target = "/api/blocklists", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads\"}", .status = 201, .check = jsonShape(SourceEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .read, .target = "/api/blocklists/1", .status = 200, .check = jsonShape(sources_repo.SourceRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200, .check = jsonShape(SourceEcho) },
|
||||
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .target = "/api/blocklists/1", .status = 204, .kind = .none },
|
||||
|
||||
// Rules. The lookup below wants the blocking rule still in place, so the
|
||||
// rule's delete follows it.
|
||||
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .target = "/api/rules", .status = 200, .check = jsonShape(RulesList) },
|
||||
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201, .check = jsonShape(RuleEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .status = 200, .check = jsonShape(RuleShape) },
|
||||
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 200, .check = jsonShape(RuleEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .target = "/api/lookup?domain=ads.example", .status = 200, .check = jsonShape(handlers_lookup.Body) },
|
||||
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .status = 204, .kind = .none },
|
||||
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .policy = .read, .target = "/api/rules", .status = 200, .check = jsonShape(RulesList) },
|
||||
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .policy = .config_write, .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201, .check = jsonShape(RuleEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .policy = .read, .target = "/api/rules/1", .status = 200, .check = jsonShape(RuleShape) },
|
||||
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 200, .check = jsonShape(RuleEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .target = "/api/lookup?domain=ads.example", .status = 200, .check = jsonShape(handlers_lookup.Body) },
|
||||
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .target = "/api/rules/1", .status = 204, .kind = .none },
|
||||
|
||||
// Local records.
|
||||
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .target = "/api/local-records", .status = 200, .check = jsonShape(RecordsList) },
|
||||
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201, .check = jsonShape(RecordShape) },
|
||||
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .status = 200, .check = jsonShape(RecordShape) },
|
||||
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200, .check = jsonShape(RecordShape) },
|
||||
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .status = 204, .kind = .none },
|
||||
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .policy = .read, .target = "/api/local-records", .status = 200, .check = jsonShape(RecordsList) },
|
||||
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .policy = .config_write, .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201, .check = jsonShape(RecordShape) },
|
||||
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .read, .target = "/api/local-records/1", .status = 200, .check = jsonShape(RecordShape) },
|
||||
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200, .check = jsonShape(RecordShape) },
|
||||
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .target = "/api/local-records/1", .status = 204, .kind = .none },
|
||||
|
||||
// Forward zones.
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .target = "/api/forward-zones", .status = 200, .check = jsonShape(ZonesList) },
|
||||
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201, .check = jsonShape(local_repo.ForwardZoneRow) },
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
|
||||
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .status = 204, .kind = .none },
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .policy = .read, .target = "/api/forward-zones", .status = 200, .check = jsonShape(ZonesList) },
|
||||
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .policy = .config_write, .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201, .check = jsonShape(local_repo.ForwardZoneRow) },
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .read, .target = "/api/forward-zones/1", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
|
||||
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .target = "/api/forward-zones/1", .status = 204, .kind = .none },
|
||||
|
||||
// Clients (row id 1 is seeded — clients have no POST, ruling 9).
|
||||
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .target = "/api/clients", .status = 200, .check = jsonShape(ClientsList) },
|
||||
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
|
||||
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .status = 204, .kind = .none },
|
||||
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .target = "/api/client-prefixes", .status = 200, .check = jsonShape(PrefixesList) },
|
||||
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200, .check = jsonShape(PrefixesList) },
|
||||
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .policy = .read, .target = "/api/clients", .status = 200, .check = jsonShape(ClientsList) },
|
||||
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .policy = .read, .target = "/api/clients/1", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .policy = .config_write, .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
|
||||
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .policy = .runtime_action, .target = "/api/clients/1", .status = 204, .kind = .none },
|
||||
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .policy = .read, .target = "/api/client-prefixes", .status = 200, .check = jsonShape(PrefixesList) },
|
||||
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .policy = .config_write, .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200, .check = jsonShape(PrefixesList) },
|
||||
|
||||
// Upstreams. Row id 1 is seeded; the POST creates id 2, whose delete
|
||||
// cannot collide with the last-enabled-upstream guard.
|
||||
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .target = "/api/upstreams", .status = 200, .check = jsonShape(UpstreamsList) },
|
||||
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201, .check = jsonShape(UpstreamEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/1", .status = 200, .check = jsonShape(upstreams_repo.UpstreamRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200, .check = jsonShape(UpstreamEcho) },
|
||||
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/2", .status = 204, .kind = .none },
|
||||
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .policy = .read, .target = "/api/upstreams", .status = 200, .check = jsonShape(UpstreamsList) },
|
||||
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .policy = .config_write, .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201, .check = jsonShape(UpstreamEcho) },
|
||||
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .read, .target = "/api/upstreams/1", .status = 200, .check = jsonShape(upstreams_repo.UpstreamRow) },
|
||||
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200, .check = jsonShape(UpstreamEcho) },
|
||||
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .target = "/api/upstreams/2", .status = 204, .kind = .none },
|
||||
|
||||
// Pause and settings. The pause POST leaves filtering running; the
|
||||
// settings PUT is a real change, echoed by the same response shape.
|
||||
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .target = "/api/pause", .status = 200, .check = jsonShape(handlers_pause.View) },
|
||||
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .target = "/api/pause", .body = "{\"paused\":false}", .status = 200, .check = jsonShape(handlers_pause.View) },
|
||||
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .target = "/api/settings", .status = 200, .check = jsonShape(SettingsView) },
|
||||
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200, .check = jsonShape(SettingsView) },
|
||||
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .policy = .read, .target = "/api/pause", .status = 200, .check = jsonShape(handlers_pause.View) },
|
||||
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .target = "/api/pause", .body = "{\"paused\":false}", .status = 200, .check = jsonShape(handlers_pause.View) },
|
||||
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .policy = .read, .target = "/api/settings", .status = 200, .check = jsonShape(SettingsView) },
|
||||
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .policy = .config_write, .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200, .check = jsonShape(SettingsView) },
|
||||
|
||||
// Certificates. The walk's environment wires no cert store, so both
|
||||
// endpoints report disabled — and the reload still answers 200 (m10
|
||||
// ruling 8: the outcome is the payload).
|
||||
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .target = "/api/certs/reload", .status = 200, .check = jsonShape(handlers_certs.View) },
|
||||
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .target = "/api/certs/reload", .status = 200, .check = jsonShape(handlers_certs.View) },
|
||||
|
||||
// The walk's last delete returns the groups table to its seeded shape.
|
||||
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .status = 204, .kind = .none },
|
||||
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .target = "/api/groups/2", .status = 204, .kind = .none },
|
||||
};
|
||||
|
||||
// Drift guard: the contract table covers the served route table exactly —
|
||||
@@ -707,6 +717,7 @@ test "the contract table covers every served route with the served policy" {
|
||||
covered[index] = true;
|
||||
try testing.expectEqual(route.auth, entry.auth);
|
||||
try testing.expectEqual(route.rate_limit, entry.rate_limit);
|
||||
try testing.expectEqual(route.policy, entry.policy);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
@@ -933,6 +944,252 @@ test "W10 auth off: an empty hash leaves every route open" {
|
||||
try bounded(env.io(), default_budget, authOff, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// file authority (milestone-20 ruling 7)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const managed_path = "/etc/nxdns/config.zon";
|
||||
const managed_body = "{\"error\":\"configuration is managed by " ++ managed_path ++
|
||||
"; edit the file and restart\"}";
|
||||
|
||||
/// Long enough that the envelope could not be built in the 512-byte stack
|
||||
/// buffer `respondError` used before this milestone. Nested bind mounts really
|
||||
/// do produce paths like this, and the old code answered them in `text/plain`.
|
||||
const long_managed_path = "/mnt/" ++ ("deeply-nested-bind-mount/" ** 24) ++ "config.zon";
|
||||
|
||||
fn fileModeClasses(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [8192]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
// A read is untouched.
|
||||
try conn.request("GET", "/api/groups", null, null);
|
||||
var response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
// Every class of configuration write answers the one envelope.
|
||||
const writes = [_]struct { method: []const u8, target: []const u8, body: ?[]const u8 }{
|
||||
.{ .method = "POST", .target = "/api/groups", .body = "{\"name\":\"kids\"}" },
|
||||
.{ .method = "PUT", .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}" },
|
||||
.{ .method = "PUT", .target = "/api/clients/1", .body = "{\"name\":\"x\",\"group_id\":1}" },
|
||||
.{ .method = "DELETE", .target = "/api/upstreams/1", .body = null },
|
||||
};
|
||||
for (writes) |write| {
|
||||
try conn.request(write.method, write.target, null, write.body);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 403), response.status);
|
||||
try testing.expectEqualStrings(managed_body, response.body);
|
||||
try testing.expectEqualStrings("application/json", response.header("content-type").?);
|
||||
}
|
||||
|
||||
// Rejected before the handler, not after it: the group was never created.
|
||||
try conn.request("GET", "/api/groups", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "kids"));
|
||||
|
||||
// Runtime actions stay live.
|
||||
try conn.request("POST", "/api/pause", null, "{\"paused\":false}");
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
try conn.request("POST", "/api/blocklists/update", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 202), response.status);
|
||||
|
||||
try conn.request("POST", "/api/certs/reload", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
}
|
||||
|
||||
test "W10 milestone 20: file authority rejects configuration writes and spares the rest" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = managed_path } });
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, fileModeClasses, .{ env.io(), env });
|
||||
}
|
||||
|
||||
fn fileModeClientDelete(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [4096]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
// The declared row contradicts the file, so it stays.
|
||||
try conn.request("DELETE", "/api/clients/2", null, null);
|
||||
var response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 403), response.status);
|
||||
try testing.expectEqualStrings(managed_body, response.body);
|
||||
|
||||
// The observed row is runtime state the file never declared; without this
|
||||
// a departed device would be immortal, since the file can only promote an
|
||||
// address, never forget one.
|
||||
try conn.request("DELETE", "/api/clients/1", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 204), response.status);
|
||||
|
||||
// An id no client holds is still a 404, not a policy verdict.
|
||||
try conn.request("DELETE", "/api/clients/999", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 404), response.status);
|
||||
}
|
||||
|
||||
test "W10 milestone 20: file authority deletes an observed client and refuses a declared one" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = managed_path } });
|
||||
defer env.destroy();
|
||||
|
||||
// Row 1 is seeded observed (`hand_edited = 0`); row 2 is what the file
|
||||
// declares.
|
||||
try env.config_db.exec(
|
||||
\\INSERT INTO clients (id, ip, name, group_id, hand_edited, first_seen, last_seen)
|
||||
\\VALUES (2, '192.168.1.51', 'nas', 1, 1, 1700000000, 1700000000)
|
||||
);
|
||||
|
||||
try bounded(env.io(), default_budget, fileModeClientDelete, .{ env.io(), env });
|
||||
}
|
||||
|
||||
fn longPathEnvelope(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [8192]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
|
||||
const response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 403), response.status);
|
||||
try testing.expect(response.body.len > 512);
|
||||
try testing.expectEqualStrings("application/json", response.header("content-type").?);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, long_managed_path));
|
||||
|
||||
// Still the documented envelope, not a truncation and not plain text.
|
||||
const parsed = try std.json.parseFromSlice(
|
||||
struct { @"error": []const u8 },
|
||||
env.gpa,
|
||||
response.body,
|
||||
.{},
|
||||
);
|
||||
defer parsed.deinit();
|
||||
}
|
||||
|
||||
test "W10 milestone 20: an error longer than the old 512-byte buffer stays application/json" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = long_managed_path } });
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, longPathEnvelope, .{ env.io(), env });
|
||||
}
|
||||
|
||||
fn fileModeUnauthenticated(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [4096]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
// Policy runs after authentication: a caller with no session learns that
|
||||
// it needs one, never that the route exists and is managed by a file whose
|
||||
// path the envelope would otherwise disclose.
|
||||
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
|
||||
const response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 401), response.status);
|
||||
try testing.expectEqualStrings("{\"error\":\"authentication required\"}", response.body);
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, managed_path));
|
||||
}
|
||||
|
||||
test "W10 milestone 20: an unauthenticated configuration write is 401, never 403" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var hash_buf: [256]u8 = undefined;
|
||||
const hash = try hashTestPassword(gpa, &hash_buf);
|
||||
|
||||
var env = try Env.create(gpa, .{
|
||||
.password_hash = hash,
|
||||
.authority = .{ .managed_file = managed_path },
|
||||
});
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, fileModeUnauthenticated, .{ env.io(), env });
|
||||
}
|
||||
|
||||
fn authorityEnvelope(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [16384]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
try conn.request("GET", "/api/settings", null, null);
|
||||
var response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{});
|
||||
defer parsed.deinit();
|
||||
try testing.expectEqualStrings("managed_file", parsed.value.authority.mode);
|
||||
try testing.expectEqualStrings(managed_path, parsed.value.authority.path.?);
|
||||
try testing.expectEqual(@as(?i64, 1_700_000_042), parsed.value.authority.reconciled_at);
|
||||
|
||||
// The path is a filesystem path and must not reach the open routes.
|
||||
for ([_][]const u8{ "/api/version", "/api/health" }) |target| {
|
||||
try conn.request("GET", target, null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, managed_path));
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "authority"));
|
||||
}
|
||||
}
|
||||
|
||||
test "W10 milestone 20: the settings envelope reports the authority and the open routes do not" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{
|
||||
.authority = .{ .managed_file = managed_path },
|
||||
.reconciled_at = 1_700_000_042,
|
||||
});
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, authorityEnvelope, .{ env.io(), env });
|
||||
}
|
||||
|
||||
fn databaseAuthorityEnvelope(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [16384]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
try conn.request("GET", "/api/settings", null, null);
|
||||
const response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{});
|
||||
defer parsed.deinit();
|
||||
try testing.expectEqualStrings("database", parsed.value.authority.mode);
|
||||
try testing.expectEqual(@as(?[]const u8, null), parsed.value.authority.path);
|
||||
try testing.expectEqual(@as(?i64, null), parsed.value.authority.reconciled_at);
|
||||
|
||||
// And nothing is rejected.
|
||||
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
|
||||
const created = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 201), created.status);
|
||||
}
|
||||
|
||||
test "W10 milestone 20: database authority reports null and writes normally" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{});
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, databaseAuthorityEnvelope, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// oversized cookie headers (ruling 7 of milestone 16)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ApiError } from "@/lib/api";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import type { Blocklist, BlocklistInput } from "@/lib/types";
|
||||
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
|
||||
import { READ_ONLY_HINT } from "@/features/settings/authority";
|
||||
|
||||
/**
|
||||
* Drops the rejection the page already renders inline below the form. Anything
|
||||
@@ -17,12 +18,14 @@ export function swallowMutationError(error: unknown): void {
|
||||
interface BlocklistFormProps {
|
||||
initial?: Blocklist;
|
||||
busy: boolean;
|
||||
/** File authority: the server answers 403, so the submit stays down. */
|
||||
readOnly: boolean;
|
||||
error: Error | null;
|
||||
onSubmit: (input: BlocklistInput) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel }: BlocklistFormProps) {
|
||||
export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit, onCancel }: BlocklistFormProps) {
|
||||
const [url, setUrl] = useState(initial?.url ?? "");
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
|
||||
@@ -81,7 +84,12 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
|
||||
Enabled
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="submit" disabled={busy} className={primaryButtonClass}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
{initial === undefined ? "Add source" : "Save changes"}
|
||||
</button>
|
||||
{onCancel !== undefined && (
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
tdClass,
|
||||
thClass,
|
||||
} from "@/ui/classes";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
export default function BlocklistsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -36,6 +37,9 @@ export default function BlocklistsPage() {
|
||||
|
||||
const sources = useRefreshStatus();
|
||||
const namesById = new Map(blocklists.map((b) => [b.id, b.name]));
|
||||
// The refresh below re-fetches the sources the config already declares, so
|
||||
// it stays live in file mode; every other control here writes config.
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
async function submitForm(input: BlocklistInput) {
|
||||
if (editing === null) {
|
||||
@@ -122,7 +126,8 @@ export default function BlocklistsPage() {
|
||||
type="checkbox"
|
||||
aria-label={`${b.name} enabled`}
|
||||
checked={b.enabled}
|
||||
disabled={toggle.isPending}
|
||||
disabled={toggle.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
onChange={() => toggleEnabled(b)}
|
||||
className={focusRing}
|
||||
/>
|
||||
@@ -138,14 +143,17 @@ export default function BlocklistsPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(b)}
|
||||
className={linkButtonClass}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={`${linkButtonClass} disabled:opacity-50`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteBlocklist(b)}
|
||||
disabled={remove.isPending}
|
||||
disabled={remove.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={dangerLinkButtonClass}
|
||||
>
|
||||
Delete
|
||||
@@ -164,6 +172,7 @@ export default function BlocklistsPage() {
|
||||
key={editing?.id ?? "add"}
|
||||
initial={editing ?? undefined}
|
||||
busy={editing === null ? create.isPending : save.isPending}
|
||||
readOnly={readOnly}
|
||||
error={formError}
|
||||
onSubmit={submitForm}
|
||||
onCancel={editing === null ? undefined : () => setEditing(null)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { clientUpdateMutation } from "@/lib/queries";
|
||||
import type { Client, Group } from "@/lib/types";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { buttonClass, primaryButtonClass, smallInputClass } from "@/ui/classes";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
interface Props {
|
||||
client: Client;
|
||||
@@ -18,6 +19,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
||||
const mutation = useMutation(clientUpdateMutation(queryClient));
|
||||
const [name, setName] = useState(client.name);
|
||||
const [groupId, setGroupId] = useState(client.group_id);
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
@@ -67,7 +69,12 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
||||
<button type="button" onClick={onClose} className={buttonClass}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={mutation.isPending} className={primaryButtonClass}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,17 @@ import ClientEditDialog from "./ClientEditDialog";
|
||||
import PrefixesEditor from "./PrefixesEditor";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { smallButtonClass, tableWrapClass } from "@/ui/classes";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
const cellClass = "px-3 py-2";
|
||||
|
||||
/**
|
||||
* Deleting an observed row discards runtime state the file never declared, so
|
||||
* it stays live under file authority; deleting a hand-edited row contradicts
|
||||
* the file and is the one client DELETE the server answers 403 (ruling 7).
|
||||
*/
|
||||
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart.";
|
||||
|
||||
export default function ClientsPage() {
|
||||
const { data: clients } = useSuspenseQuery(clientsQuery());
|
||||
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
|
||||
@@ -18,6 +26,7 @@ export default function ClientsPage() {
|
||||
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
|
||||
const [editing, setEditing] = useState<Client | null>(null);
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
return (
|
||||
<section>
|
||||
@@ -86,14 +95,22 @@ export default function ClientsPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(client)}
|
||||
className={smallButtonClass}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={`${smallButtonClass} disabled:opacity-50`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingId(client.id)}
|
||||
className={`${smallButtonClass} text-red-700 dark:text-red-400`}
|
||||
disabled={readOnly && client.hand_edited}
|
||||
title={
|
||||
readOnly && client.hand_edited
|
||||
? DECLARED_CLIENT_NOTE
|
||||
: undefined
|
||||
}
|
||||
className={`${smallButtonClass} text-red-700 disabled:opacity-50 dark:text-red-400`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { defaultGroupId } from "@/lib/defaultGroup";
|
||||
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
interface Props {
|
||||
prefixes: ClientPrefix[];
|
||||
@@ -19,6 +20,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||
const [validation, setValidation] = useState<string | null>(null);
|
||||
const dirty = isDirty(state);
|
||||
const fallbackGroupId = defaultGroupId(groups);
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
const save = () => {
|
||||
const problem = firstProblem(state.rows);
|
||||
@@ -105,7 +107,8 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={!dirty || mutation.isPending}
|
||||
disabled={!dirty || mutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
Save prefixes
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Blocklist } from "@/lib/types";
|
||||
import { sameSet, toggleSource } from "./sourceSet";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { buttonClass, focusRing, primaryButtonClass } from "@/ui/classes";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
interface Props {
|
||||
groupId: number;
|
||||
@@ -16,6 +17,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
||||
const sources = useQuery(groupSourcesQuery(groupId));
|
||||
const mutation = useMutation(groupSourcesPutMutation(queryClient));
|
||||
const [selected, setSelected] = useState<number[] | null>(null);
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
if (sources.isPending) {
|
||||
return (
|
||||
@@ -58,7 +60,8 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || mutation.isPending}
|
||||
disabled={!dirty || mutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
onClick={() =>
|
||||
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import GroupSourcesEditor from "./GroupSourcesEditor";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { focusRing, primaryButtonClass, smallButtonClass, smallInputClass } from "@/ui/classes";
|
||||
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
|
||||
|
||||
@@ -23,6 +24,7 @@ export default function GroupsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const createMutation = useMutation(groupCreateMutation(queryClient));
|
||||
const [newName, setNewName] = useState("");
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
return (
|
||||
<section>
|
||||
@@ -44,9 +46,15 @@ export default function GroupsPage() {
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(event) => setNewName(event.target.value)}
|
||||
disabled={readOnly}
|
||||
className={smallInputClass}
|
||||
/>
|
||||
<button type="submit" disabled={createMutation.isPending} className={primaryButtonClass}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
@@ -69,6 +77,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isDefault = group.id === DEFAULT_GROUP_ID;
|
||||
const readOnly = useReadOnlyConfig();
|
||||
const lockNote = isDefault ? DEFAULT_GROUP_NOTE : readOnly ? READ_ONLY_HINT : undefined;
|
||||
|
||||
return (
|
||||
<li className="rounded border border-zinc-200 p-4 dark:border-zinc-700">
|
||||
@@ -94,7 +104,12 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
||||
className={smallInputClass}
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" disabled={updateMutation.isPending} className={groupButtonClass}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={groupButtonClass}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
@@ -115,7 +130,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={group.safe_search}
|
||||
disabled={updateMutation.isPending}
|
||||
disabled={updateMutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={focusRing}
|
||||
onChange={(event) =>
|
||||
updateMutation.mutate({
|
||||
@@ -138,8 +154,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
||||
{!renaming && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDefault}
|
||||
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
|
||||
disabled={isDefault || readOnly}
|
||||
title={lockNote}
|
||||
onClick={() => {
|
||||
setName(group.name);
|
||||
setRenaming(true);
|
||||
@@ -168,8 +184,8 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDefault}
|
||||
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
|
||||
disabled={isDefault || readOnly}
|
||||
title={lockNote}
|
||||
onClick={() => setConfirming(true)}
|
||||
className={`${groupButtonClass} text-red-700 dark:text-red-400`}
|
||||
>
|
||||
|
||||
@@ -17,18 +17,21 @@ import {
|
||||
rowButtonClass,
|
||||
tableWrapClass,
|
||||
} from "@/ui/classes";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
|
||||
|
||||
function RecordForm({
|
||||
initial,
|
||||
busy,
|
||||
readOnly,
|
||||
error,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: LocalRecord;
|
||||
busy: boolean;
|
||||
readOnly: boolean;
|
||||
error: unknown;
|
||||
onSubmit: (input: LocalRecordInput) => void;
|
||||
onCancel: () => void;
|
||||
@@ -109,7 +112,12 @@ function RecordForm({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={busy} className={largePrimaryButtonClass}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={largePrimaryButtonClass}
|
||||
>
|
||||
{busy ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className={largeButtonClass}>
|
||||
@@ -132,18 +140,31 @@ export default function RecordsTab() {
|
||||
remove: localRecordDeleteMutation,
|
||||
confirmDelete: (record) => `Delete record "${record.name}"?`,
|
||||
});
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-500">Answers served directly for LAN names. Changes apply live.</p>
|
||||
<button type="button" onClick={() => openForm({ mode: "create" })} className={largePrimaryButtonClass}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openForm({ mode: "create" })}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={largePrimaryButtonClass}
|
||||
>
|
||||
Add record
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={remove.error} />
|
||||
{form?.mode === "create" && (
|
||||
<RecordForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
|
||||
<RecordForm
|
||||
busy={create.isPending}
|
||||
readOnly={readOnly}
|
||||
error={create.error}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={closeForm}
|
||||
/>
|
||||
)}
|
||||
<div className={tableWrapClass}>
|
||||
<table className="w-full text-left text-sm">
|
||||
@@ -181,6 +202,7 @@ export default function RecordsTab() {
|
||||
<RecordForm
|
||||
initial={record}
|
||||
busy={update.isPending}
|
||||
readOnly={readOnly}
|
||||
error={update.error}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={closeForm}
|
||||
@@ -196,15 +218,18 @@ export default function RecordsTab() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openForm({ mode: "edit", entity: record })}
|
||||
className={rowButtonClass}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={`${rowButtonClass} disabled:opacity-50`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(record)}
|
||||
disabled={remove.isPending}
|
||||
className={`${rowButtonClass} text-red-600 dark:text-red-400`}
|
||||
disabled={remove.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -17,16 +17,19 @@ import {
|
||||
rowButtonClass,
|
||||
tableWrapClass,
|
||||
} from "@/ui/classes";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
function ZoneForm({
|
||||
initial,
|
||||
busy,
|
||||
readOnly,
|
||||
error,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: ForwardZone;
|
||||
busy: boolean;
|
||||
readOnly: boolean;
|
||||
error: unknown;
|
||||
onSubmit: (input: ForwardZoneInput) => void;
|
||||
onCancel: () => void;
|
||||
@@ -70,7 +73,12 @@ function ZoneForm({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={busy} className={largePrimaryButtonClass}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={largePrimaryButtonClass}
|
||||
>
|
||||
{busy ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className={largeButtonClass}>
|
||||
@@ -93,6 +101,7 @@ export default function ZonesTab() {
|
||||
remove: forwardZoneDeleteMutation,
|
||||
confirmDelete: (zone) => `Delete forward zone "${zone.zone}"?`,
|
||||
});
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -100,13 +109,25 @@ export default function ZonesTab() {
|
||||
<p className="text-sm text-zinc-500">
|
||||
Names under these zones go to their own resolver. Changes apply live.
|
||||
</p>
|
||||
<button type="button" onClick={() => openForm({ mode: "create" })} className={largePrimaryButtonClass}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openForm({ mode: "create" })}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={largePrimaryButtonClass}
|
||||
>
|
||||
Add zone
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={remove.error} />
|
||||
{form?.mode === "create" && (
|
||||
<ZoneForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
|
||||
<ZoneForm
|
||||
busy={create.isPending}
|
||||
readOnly={readOnly}
|
||||
error={create.error}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={closeForm}
|
||||
/>
|
||||
)}
|
||||
<div className={tableWrapClass}>
|
||||
<table className="w-full text-left text-sm">
|
||||
@@ -138,6 +159,7 @@ export default function ZonesTab() {
|
||||
<ZoneForm
|
||||
initial={zone}
|
||||
busy={update.isPending}
|
||||
readOnly={readOnly}
|
||||
error={update.error}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={closeForm}
|
||||
@@ -151,15 +173,18 @@ export default function ZonesTab() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openForm({ mode: "edit", entity: zone })}
|
||||
className={rowButtonClass}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={`${rowButtonClass} disabled:opacity-50`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(zone)}
|
||||
disabled={remove.isPending}
|
||||
className={`${rowButtonClass} text-red-600 dark:text-red-400`}
|
||||
disabled={remove.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from
|
||||
import type { Rule, RuleAction, RuleKind } from "@/lib/types";
|
||||
import { defaultGroupId } from "@/lib/defaultGroup";
|
||||
import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
export default function RulesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -19,6 +20,7 @@ export default function RulesPage() {
|
||||
const [kind, setKind] = useState<RuleKind>("exact");
|
||||
const [action, setAction] = useState<RuleAction>("block");
|
||||
const [groupId, setGroupId] = useState(() => defaultGroupId(groups));
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -77,7 +79,8 @@ export default function RulesPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteRule(rule)}
|
||||
disabled={remove.isPending}
|
||||
disabled={remove.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={dangerLinkButtonClass}
|
||||
>
|
||||
Delete
|
||||
@@ -154,7 +157,12 @@ export default function RulesPage() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" disabled={create.isPending} className={primaryButtonClass}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={create.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
{create.isPending ? "Creating…" : "Create rule"}
|
||||
</button>
|
||||
<InlineError error={create.error} />
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useAuthority } from "./authority";
|
||||
|
||||
/**
|
||||
* File authority is a standing condition, not an event, so this banner has no
|
||||
* dismiss button: it stays up for as long as the process runs from a file.
|
||||
*/
|
||||
export default function ReadOnlyConfigBanner() {
|
||||
const authority = useAuthority();
|
||||
if (authority?.mode !== "managed_file") return null;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100"
|
||||
>
|
||||
Configuration is managed by <code className="font-mono">{authority.path}</code>. Edit the file and restart
|
||||
nxdns to change it; the server rejects edits made here.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { settingsPutMutation, settingsQuery } from "@/lib/queries";
|
||||
import { buildSettingsPatch } from "@/lib/settingsDiff";
|
||||
import type { Settings, SettingsPatch } from "@/lib/types";
|
||||
import { raiseRestartBanner } from "./restartBanner";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority";
|
||||
import { focusRing } from "@/ui/classes";
|
||||
|
||||
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */
|
||||
@@ -242,7 +243,8 @@ export default function SettingsPage() {
|
||||
);
|
||||
});
|
||||
const patch = buildSettingsPatch(baseline, edited, password === "" ? undefined : password);
|
||||
const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending;
|
||||
const readOnly = useReadOnlyConfig();
|
||||
const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending || readOnly;
|
||||
|
||||
function setField(section: keyof Settings, key: string, value: unknown): void {
|
||||
setEdited((prev) => ({
|
||||
@@ -273,7 +275,7 @@ export default function SettingsPage() {
|
||||
Changes are validated as a whole; every setting requires a restart to take effect.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="mt-4 max-w-3xl">
|
||||
<fieldset disabled={mutation.isPending} className="space-y-6">
|
||||
<fieldset disabled={mutation.isPending || readOnly} className="space-y-6">
|
||||
{SECTIONS.map(({ section, title, fields }) => (
|
||||
<fieldset key={section} className="rounded border border-zinc-200 p-4 dark:border-zinc-800">
|
||||
<legend className="px-1 text-sm font-semibold">{title}</legend>
|
||||
@@ -339,6 +341,7 @@ export default function SettingsPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saveDisabled}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={`rounded bg-blue-600 px-4 py-1.5 text-sm font-medium text-white ${focusRing} disabled:bg-zinc-300 disabled:text-zinc-500 dark:disabled:bg-zinc-800`}
|
||||
>
|
||||
{mutation.isPending ? "Saving…" : "Save"}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import type { Authority, Settings, SettingsEnvelope } from "@/lib/types";
|
||||
|
||||
// One file for the whole file-mode sweep: the settings envelope is the only
|
||||
// discovery mechanism, so every page test needs the same stubbed envelope.
|
||||
|
||||
const CONFIG_PATH = "/etc/nxdns/config.zon";
|
||||
|
||||
const DATABASE: Authority = { mode: "database", path: null, reconciled_at: null };
|
||||
const MANAGED_FILE: Authority = { mode: "managed_file", path: CONFIG_PATH, reconciled_at: 1754899200 };
|
||||
|
||||
function baseSettings(): Settings {
|
||||
return {
|
||||
upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 },
|
||||
dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
|
||||
blocking: { response: "zero", ttl: 300 },
|
||||
cache: { size: 10000, negative_ttl_max: 300 },
|
||||
web: {
|
||||
enabled: true,
|
||||
bind: "127.0.0.1",
|
||||
port: 8080,
|
||||
session_ttl_hours: 24,
|
||||
api_rate_limit_per_min: 60,
|
||||
api_localhost_exempt: true,
|
||||
sse_max_connections_per_ip: 2,
|
||||
trusted_proxies: "",
|
||||
auth_enabled: true,
|
||||
},
|
||||
doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" },
|
||||
dot_server: { enabled: false, bind: "0.0.0.0", port: 853, cert_path: "", key_path: "" },
|
||||
edns: { ecs_mode: "strip" },
|
||||
logging: {
|
||||
level: "info",
|
||||
retention_days: 30,
|
||||
query_log_buffer_max: 10000,
|
||||
hide_domains: false,
|
||||
hide_client_ips: false,
|
||||
output: "stderr",
|
||||
file_path: "",
|
||||
max_size_mb: 50,
|
||||
max_files: 3,
|
||||
},
|
||||
disk: { min_free_mb: 100, warn_free_mb: 500 },
|
||||
blocklist_update: { enabled: true, interval_hours: 24 },
|
||||
};
|
||||
}
|
||||
|
||||
function envelope(authority: Authority): SettingsEnvelope {
|
||||
return { settings: baseSettings(), restart_required: [], authority };
|
||||
}
|
||||
|
||||
const GROUPS = {
|
||||
groups: [
|
||||
{ id: 1, name: "default", safe_search: false },
|
||||
{ id: 2, name: "kids", safe_search: true },
|
||||
],
|
||||
};
|
||||
|
||||
const BLOCKLISTS = {
|
||||
blocklists: [
|
||||
{
|
||||
id: 1,
|
||||
url: "https://example.com/ads.txt",
|
||||
name: "Ads",
|
||||
enabled: true,
|
||||
is_suggested: false,
|
||||
last_updated: null,
|
||||
domain_count: 100,
|
||||
wildcard_count: 0,
|
||||
skipped_regex_count: 0,
|
||||
checksum: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const RULES = {
|
||||
rules: [
|
||||
{
|
||||
id: 1,
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
pattern: "ads.example.com",
|
||||
kind: "exact",
|
||||
action: "block",
|
||||
created_at: 1700000000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const CLIENTS = {
|
||||
clients: [
|
||||
{
|
||||
id: 1,
|
||||
ip: "192.168.1.10",
|
||||
name: "laptop",
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
hand_edited: true,
|
||||
first_seen: 1700000000,
|
||||
last_seen: 1700003600,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
ip: "192.168.1.11",
|
||||
name: "",
|
||||
group_id: 2,
|
||||
group: "kids",
|
||||
hand_edited: false,
|
||||
first_seen: 1700000000,
|
||||
last_seen: 1700007200,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const PREFIXES = {
|
||||
client_prefixes: [{ id: 1, prefix: "192.168.1.0/24", group_id: 2, group: "kids", priority: 100 }],
|
||||
};
|
||||
|
||||
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||
|
||||
const BASE: Record<string, unknown> = {
|
||||
"GET /api/version": VERSION,
|
||||
"GET /api/groups": GROUPS,
|
||||
"GET /api/blocklists": BLOCKLISTS,
|
||||
"GET /api/rules": RULES,
|
||||
"GET /api/clients": CLIENTS,
|
||||
"GET /api/client-prefixes": PREFIXES,
|
||||
};
|
||||
|
||||
function stubFetch(map: Record<string, unknown>) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const key = `${init?.method ?? "GET"} ${String(input)}`;
|
||||
const payload = map[key];
|
||||
if (payload === undefined) {
|
||||
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
|
||||
}
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function renderAt(route: string, heading: string, authority: Authority) {
|
||||
stubFetch({ ...BASE, "GET /api/settings": envelope(authority) });
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: [route] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
await screen.findByRole("heading", { name: heading });
|
||||
}
|
||||
|
||||
function button(name: string): HTMLButtonElement {
|
||||
return screen.getByRole("button", { name }) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function clientRow(ip: string): HTMLElement {
|
||||
const row = screen.getByText(ip).closest("tr");
|
||||
if (row === null) throw new Error(`no client row for ${ip}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("the banner names the managed file in file mode", async () => {
|
||||
await renderAt("/rules", "Rules", MANAGED_FILE);
|
||||
|
||||
const banner = await screen.findByText(/configuration is managed by/i);
|
||||
expect(banner.textContent).toContain(CONFIG_PATH);
|
||||
expect(banner.textContent).toMatch(/restart/i);
|
||||
expect(banner.closest('[role="status"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the banner is absent in database mode", async () => {
|
||||
await renderAt("/rules", "Rules", DATABASE);
|
||||
|
||||
await screen.findByRole("button", { name: "Create rule" });
|
||||
expect(screen.queryByText(/configuration is managed by/i)).toBeNull();
|
||||
});
|
||||
|
||||
function kidsRow(): HTMLElement {
|
||||
const row = screen.getByText("kids").closest("li");
|
||||
if (row === null) throw new Error("no row for group kids");
|
||||
return row;
|
||||
}
|
||||
|
||||
test("file mode disables the Groups create and delete controls", async () => {
|
||||
await renderAt("/groups", "Groups", MANAGED_FILE);
|
||||
await screen.findByText(/configuration is managed by/i);
|
||||
|
||||
expect(button("Create").disabled).toBe(true);
|
||||
expect((within(kidsRow()).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((within(kidsRow()).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("database mode leaves the Groups create and delete controls enabled", async () => {
|
||||
await renderAt("/groups", "Groups", DATABASE);
|
||||
await screen.findByRole("button", { name: "Create" });
|
||||
|
||||
expect(button("Create").disabled).toBe(false);
|
||||
expect((within(kidsRow()).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((within(kidsRow()).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("file mode disables the Rules create and delete controls", async () => {
|
||||
await renderAt("/rules", "Rules", MANAGED_FILE);
|
||||
await screen.findByText(/configuration is managed by/i);
|
||||
|
||||
expect(button("Create rule").disabled).toBe(true);
|
||||
expect(button("Delete").disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("database mode leaves the Rules create and delete controls enabled", async () => {
|
||||
await renderAt("/rules", "Rules", DATABASE);
|
||||
await screen.findByRole("button", { name: "Create rule" });
|
||||
|
||||
expect(button("Create rule").disabled).toBe(false);
|
||||
expect(button("Delete").disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("file mode keeps delete live for an observed client and blocks it for a declared one", async () => {
|
||||
await renderAt("/clients", "Clients", MANAGED_FILE);
|
||||
await screen.findByText(/configuration is managed by/i);
|
||||
|
||||
const declared = clientRow("192.168.1.10");
|
||||
const observed = clientRow("192.168.1.11");
|
||||
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((within(observed).getByRole("button", { name: "Edit" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("file mode leaves the blocklist refresh button enabled", async () => {
|
||||
await renderAt("/blocklists", "Blocklists", MANAGED_FILE);
|
||||
await screen.findByText(/configuration is managed by/i);
|
||||
|
||||
expect(button("Update now").disabled).toBe(false);
|
||||
expect(button("Add source").disabled).toBe(true);
|
||||
expect((screen.getByRole("checkbox", { name: "Ads enabled" }) as HTMLInputElement).disabled).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { settingsQuery } from "@/lib/queries";
|
||||
import type { Authority } from "@/lib/types";
|
||||
|
||||
/** The one-line explanation on every control file authority takes away. */
|
||||
export const READ_ONLY_HINT = "Configuration is managed by a file; edit the file and restart nxdns.";
|
||||
|
||||
/**
|
||||
* The running server's configuration authority, read from the settings
|
||||
* envelope — the only route that carries it. `undefined` until that query
|
||||
* resolves. Every page may call this: it is the shared `["settings"]` key, so
|
||||
* the shell's own subscription serves them all from cache.
|
||||
*/
|
||||
export function useAuthority(): Authority | undefined {
|
||||
return useQuery(settingsQuery()).data?.authority;
|
||||
}
|
||||
|
||||
/**
|
||||
* True only once the server has said a file owns the configuration. While the
|
||||
* mode is unknown nothing is disabled — the 403 is the enforcement, this is
|
||||
* the courtesy.
|
||||
*/
|
||||
export function useReadOnlyConfig(): boolean {
|
||||
return useAuthority()?.mode === "managed_file";
|
||||
}
|
||||
@@ -2,18 +2,21 @@ import { useState, type FormEvent } from "react";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import type { Upstream, UpstreamInput } from "@/lib/types";
|
||||
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
|
||||
import { READ_ONLY_HINT } from "@/features/settings/authority";
|
||||
|
||||
const DEFAULT_PRIORITY = "100";
|
||||
|
||||
interface UpstreamFormProps {
|
||||
initial?: Upstream;
|
||||
busy: boolean;
|
||||
/** File authority: the server answers 403, so the submit stays down. */
|
||||
readOnly: boolean;
|
||||
error: Error | null;
|
||||
onSubmit: (input: UpstreamInput) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel }: UpstreamFormProps) {
|
||||
export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit, onCancel }: UpstreamFormProps) {
|
||||
const [url, setUrl] = useState(initial?.url ?? "");
|
||||
const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority));
|
||||
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
|
||||
@@ -97,7 +100,12 @@ export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel
|
||||
Enabled
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="submit" disabled={busy} className={primaryButtonClass}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
{initial === undefined ? "Add upstream" : "Save changes"}
|
||||
</button>
|
||||
{onCancel !== undefined && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Upstream, UpstreamInput } from "@/lib/types";
|
||||
import { raiseRestartBanner } from "../settings/restartBanner";
|
||||
import UpstreamForm from "./UpstreamForm";
|
||||
import { dangerLinkButtonClass, focusRing, linkButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
export default function UpstreamsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -16,6 +17,7 @@ export default function UpstreamsPage() {
|
||||
const save = useMutation(upstreamUpdateMutation(queryClient));
|
||||
const toggle = useMutation(upstreamUpdateMutation(queryClient));
|
||||
const remove = useMutation(upstreamDeleteMutation(queryClient));
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
async function submitForm(input: UpstreamInput) {
|
||||
if (editing === null) {
|
||||
@@ -84,7 +86,8 @@ export default function UpstreamsPage() {
|
||||
type="checkbox"
|
||||
aria-label={`${u.url} enabled`}
|
||||
checked={u.enabled}
|
||||
disabled={toggle.isPending}
|
||||
disabled={toggle.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
onChange={() => toggleEnabled(u)}
|
||||
className={focusRing}
|
||||
/>
|
||||
@@ -95,14 +98,17 @@ export default function UpstreamsPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(u)}
|
||||
className={linkButtonClass}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={`${linkButtonClass} disabled:opacity-50`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteUpstream(u)}
|
||||
disabled={remove.isPending}
|
||||
disabled={remove.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={dangerLinkButtonClass}
|
||||
>
|
||||
Delete
|
||||
@@ -121,6 +127,7 @@ export default function UpstreamsPage() {
|
||||
key={editing?.id ?? "add"}
|
||||
initial={editing ?? undefined}
|
||||
busy={editing === null ? create.isPending : save.isPending}
|
||||
readOnly={readOnly}
|
||||
error={formError}
|
||||
onSubmit={submitForm}
|
||||
onCancel={editing === null ? undefined : () => setEditing(null)}
|
||||
|
||||
@@ -445,6 +445,11 @@ export const sample_post_pause: PauseState = {
|
||||
};
|
||||
|
||||
export const sample_get_settings: SettingsEnvelope = {
|
||||
authority: {
|
||||
mode: "database",
|
||||
path: null,
|
||||
reconciled_at: null,
|
||||
},
|
||||
restart_required: [
|
||||
"upstream.attempt_timeout_ms",
|
||||
"upstream.read_timeout_ms",
|
||||
@@ -563,6 +568,11 @@ export const sample_get_settings: SettingsEnvelope = {
|
||||
};
|
||||
|
||||
export const sample_put_settings: SettingsEnvelope = {
|
||||
authority: {
|
||||
mode: "database",
|
||||
path: null,
|
||||
reconciled_at: null,
|
||||
},
|
||||
restart_required: [
|
||||
"upstream.attempt_timeout_ms",
|
||||
"upstream.read_timeout_ms",
|
||||
|
||||
@@ -380,9 +380,23 @@ export interface Settings {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which configuration source the running process obeys. `path` and
|
||||
* `reconciled_at` are non-null only under `managed_file`: the file the process
|
||||
* loaded, and the epoch second at which it loaded it. Authority lives in the
|
||||
* invocation, never in the database, so this is the only place the UI can read
|
||||
* it — and it rides an authenticated route, never the open ones.
|
||||
*/
|
||||
export interface Authority {
|
||||
mode: "database" | "managed_file";
|
||||
path: string | null;
|
||||
reconciled_at: number | null;
|
||||
}
|
||||
|
||||
export interface SettingsEnvelope {
|
||||
settings: Settings;
|
||||
restart_required: string[];
|
||||
authority: Authority;
|
||||
}
|
||||
|
||||
export interface TlsListenerPatch {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/auth/store";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { versionQuery } from "@/lib/queries";
|
||||
import PauseWidget from "../features/pause/PauseWidget";
|
||||
import ReadOnlyConfigBanner from "../features/settings/ReadOnlyConfigBanner";
|
||||
import RestartBanner from "../features/settings/RestartBanner";
|
||||
import { buttonClass, focusRing } from "@/ui/classes";
|
||||
|
||||
@@ -112,6 +113,7 @@ export default function AppShell() {
|
||||
</div>
|
||||
</header>
|
||||
<RestartBanner />
|
||||
<ReadOnlyConfigBanner />
|
||||
{drawerOpen && (
|
||||
<div id="mobile-nav" className="border-b border-zinc-200 md:hidden dark:border-zinc-800">
|
||||
<nav aria-label="Main" className="px-2 py-2">
|
||||
|
||||
Reference in New Issue
Block a user