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

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.