209 lines
11 KiB
Markdown
209 lines
11 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 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.
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
## 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 — 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`.
|
|
|
|
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.
|
|
|
|
## 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).
|