11 KiB
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; for the commands and their exit codes see 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.
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.
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.