Files
nxdns/docs/explanation/configuration-model.md
T

9.0 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 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.

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.