179 lines
9.0 KiB
Markdown
179 lines
9.0 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. This page explains which, and why that choice is the one
|
|
that 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
|
|
|
|
The database is the truth. The file is a seed.
|
|
|
|
`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.
|
|
|
|
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.
|
|
|
|
`src/config/bootstrap.zig` is that policy and nothing else — a wrapper over
|
|
the same import path `nxdns import` uses. Three outcomes:
|
|
|
|
- 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.
|
|
|
|
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.
|
|
|
|
## Why the database wins
|
|
|
|
The alternative designs all lose data.
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
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".
|
|
|
|
The emptiness check is a real query over the content tables, not a flag: the
|
|
database counts as configured when any content table has rows, or when the
|
|
default group has been altered. One consequence is worth knowing, because it
|
|
is not obvious: clients are auto-materialised when they first send a query, so
|
|
a server that has answered even one query is "configured" and will ignore a
|
|
seed file placed there afterwards.
|
|
|
|
## 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:
|
|
|
|
```
|
|
nxdns export → canonical ZON → edit → nxdns import --force → 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.
|
|
|
|
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. 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`.
|
|
|
|
Without `--force`, import refuses a database that already has content. 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.
|
|
|
|
## 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.
|
|
|
|
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.
|
|
|
|
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 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).
|
|
|
|
## 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).
|