milestone 20: declarative configuration for iac
This commit is contained in:
+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
|
||||
|
||||
Reference in New Issue
Block a user