Gates / test-aarch64 (push) Successful in 6m45s
Gates / frontend (push) Successful in 51s
Gates / test (push) Successful in 1m37s
Gates / container (push) Failing after 7m31s
Gates / package (push) Failing after 15m14s
CI / gates (push) Failing after 24m27s
246 lines
12 KiB
Markdown
246 lines
12 KiB
Markdown
# The configuration model
|
|
|
|
nxdns is configured two ways — a ZON file and a web UI — and only one of them
|
|
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
|
|
and their exit codes see [reference/cli.md](../reference/cli.md).
|
|
|
|
## The rule
|
|
|
|
**Authority is the invocation.**
|
|
|
|
```
|
|
nxdns run the database is the truth
|
|
nxdns run --config /etc/nxdns/config.zon the file is the truth
|
|
```
|
|
|
|
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.
|
|
|
|
Two properties fall out of that, and both were chosen on purpose.
|
|
|
|
**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 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.
|
|
|
|
## Database mode
|
|
|
|
`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.
|
|
|
|
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:
|
|
|
|
```
|
|
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>`
|
|
```
|
|
|
|
## File mode
|
|
|
|
`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.
|
|
|
|
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".
|
|
|
|
Three properties make it usable rather than merely correct.
|
|
|
|
**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
|
|
|
|
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 → config.db
|
|
```
|
|
|
|
`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.
|
|
|
|
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.
|
|
|
|
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
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
## The password
|
|
|
|
`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.
|
|
|
|
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.**
|
|
|
|
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 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
|
|
[reference/files-and-directories.md](../reference/files-and-directories.md).
|