Files
nxdns/docs/reference/files-and-directories.md
mokhtar c50c6d285a
CI / test (push) Successful in 1m22s
CI / test-aarch64 (push) Successful in 4m55s
CI / frontend (push) Successful in 39s
CI / cross (push) Successful in 7m57s
CI / docker (push) Failing after 1h10m42s
milestone 17: real deadlines, validator holes, upstream editor, trusted proxies, contract samples, badvers
2026-08-07 17:55:59 +02:00

10 KiB

Files and directories

Every path nxdns reads or writes, and the mode it is created with. Source of truth: src/cli.zig (DataDir), src/storage/querylog_schema.zig (the preserved query-log databases), src/filter/manager.zig (the blocklist snapshots), src/platform/logging.zig (the log file).

The data directory

Default /var/lib/nxdns, overridable with --data-dir DIR. nxdns run and nxdns import create it and its parents at mode 0700 when it is missing; nxdns check and nxdns export do not create it. export fails if it is not there. check opens it only on the branch that resolved to the database, so an absent data directory is not in itself a failure: an explicit --config FILE never looks at the directory, and without one check falls back to the default configuration file, or prints "nothing to check" and exits 2 when neither source exists.

run, import and export go through DataDir.openConfigDb, which opens config.db read/write, chmods it to 0600, enables WAL — creating config.db-wal and config.db-shm — and then runs any pending schema migrations. So nxdns export writes to the data directory, and on a database one schema version behind it migrates it. export also creates an empty config.db if the directory exists without one.

nxdns check is the exception: it does not use that path at all. It opens config.db immutable, which is SQLITE_OPEN_READONLY plus immutable=1, so SQLite refuses every statement that would write and builds no wal-index. No config.db-wal and no config.db-shm appear beside the file, the mode is left alone, and no migration runs — a database behind this binary's schema is reported as a failure naming nxdns run as the fix. A check against a data directory leaves it byte-identical, and check reaches the database branch only when config.db is already there.

immutable=1 ignores any -wal file, so it is refused rather than used when one holds bytes: the newest settings would be invisible and check would grade older ones from the main file. That is the "uncheckpointed changes" failure in the CLI reference.

Path What it is Mode
config.db The configuration database — the single source of truth, including web.password_hash. 0600
config.db-wal, config.db-shm SQLite write-ahead log and shared-memory index for config.db. Created by run, import and export when WAL is enabled, inheriting the main file's permissions. check creates neither. 0600
querylog.db The query log: every domain every client asked for. Expendable — if it is missing or unusable it is recreated empty. 0600
querylog.db-wal, querylog.db-shm WAL sidecars for querylog.db. 0600
querylog.db.corrupt-<unix-seconds> A querylog.db that could not be used, moved aside before an empty one was created in its place. Kept, never overwritten. Whatever the renamed file had — no chmod reaches it
querylog.db.corrupt-<unix-seconds>-<n> The same, when the plain name is taken — <n> counts from 1 and rises until the name is free. Two recreates within one second is the case it exists for. The same
blocklists/ Compiled blocklist snapshots, one subdirectory of the data directory. 0700
blocklists/<id>.list Exact domains for blocklist source <id>, one per line, behind a header. 0600
blocklists/<id>.wild Wildcard entries for the same source. 0600
blocklists/<id>.raw.tmp, <id>.list.tmp, <id>.wild.tmp Transient refresh state: the downloaded body and the two compile outputs before they are published by rename. 0600

<id> is the blocklist_sources row id.

The orphan sweep

The sweep decides by id, not by suffix. It matches all five names above and deletes those whose <id> is no longer a blocklist_sources row, so the compiled .list and .wild of a removed source go, and so do a .raw.tmp, .list.tmp or .wild.tmp left behind by a refresh that was killed before it could clean up. Files belonging to a source that still has a row are never touched, whatever state they are in: the sweep holds the same lock every refresh takes, so it never reads the directory while a refresh is part-way through.

It runs at three moments:

  • at startup, before the first refresh pass — this is what collects what a killed process left behind, and the compiled files of a source deleted while the server was down;
  • before each scheduled update pass, ahead of the disk-space gate: the sweep only unlinks, so it is the one step here that can give a critically full disk room back, and gating it would keep the residue that helped fill the disk;
  • immediately after DELETE /api/blocklists/{id}, which is when an orphan is actually created in normal operation. Without it a deleted list would keep its megabytes until the next scheduled pass.

With blocklist_update.enabled = false there are no scheduled passes, so only the first and the last of those three happen.

Each deletion is logged:

info(blocklist_manager): pruned orphaned blocklist file 9999.list

A failed sweep is a warning, not an outage — leftover bytes do not justify losing the refresh pass behind them, let alone the server.

The temporaries of a source that still exists are cleaned by the refresh that owns them rather than by the sweep: each refresh deletes its own .raw.tmp, .list.tmp and .wild.tmp as it finishes, successfully or not.

A querylog.db is moved aside when it is missing nothing but usability: SQLite reports it corrupt or not a database, PRAGMA quick_check does not answer ok, or its user_version fingerprint does not match the schema. Only the main file is renamed — its -wal and -shm are deleted, because a stale WAL would be replayed into the fresh database. A missing querylog.db is created without any aside file. The rename happens inside querylog_schema.open, before the 0600 chmod, and that chmod names querylog.db and its two sidecars only — so an aside file keeps the mode the file had at rename time, which for a querylog.db nxdns itself created is 0600 and for one an operator put there is whatever they left it at. Nothing prunes the aside files; they accumulate until an operator removes them, and each one holds the same browsing history the live query log holds.

The 0600 modes are not cosmetic. config.db holds the argon2id password hash and querylog.db holds the browsing history of every client on the LAN, so both are as sensitive as each other, and a WAL file holds the same rows as the database it belongs to. SQLite creates the main database at 0644 & ~umask; nxdns chmods it to 0600 before enabling WAL, so the sidecars inherit 0600 rather than being created world-readable.

The configuration file

Default /etc/nxdns/config.zon, overridable with --config FILE. nxdns reads it and never writes it: it seeds an unconfigured database once and is ignored afterwards. nxdns does not create the file or its directory.

nxdns export --out FILE writes a ZON file at mode 0600 through a temporary file and a rename. That file carries web.password_hash, so treat exports as secrets. Without --out the export goes to stdout, where permissions are the redirect's business.

The log file

Only when logging.output = .file. The path is logging.file_path, default /var/log/nxdns/nxdns.log, and validation requires it to be absolute.

nxdns does not create the log directory. It must exist and be writable by the user the service runs as before the process starts; under systemd the unit's LogsDirectory=nxdns does that.

Rotation triggers at logging.max_size_mb MiB and keeps logging.max_files files in total, the live one included, so the highest generation on disk is logging.max_files - 1. With the default 5 that is nxdns.log plus nxdns.log.1 through nxdns.log.4. Rotation deletes the highest generation, renames each remaining one up by one, then renames the live file to .1. A max_files of 1 or 0 deletes the live file instead of renaming it. The directory holding the log file is also sampled by the disk monitor.

Path What it is Mode
logging.file_path (default /var/log/nxdns/nxdns.log) The live process log. Opened write-only and written at a tracked offset; created when it does not exist. 0666 & ~umask — nxdns never chmods it
<file_path>.1<file_path>.<max_files - 1> Rotated generations, newest first. Inherited from the live file they were renamed from

Do not point external logrotate at this file. nxdns owns the rotation of its own log. There is no append mode in Zig 0.16, so the writer reads the file length once when it opens the file and then writes every line at an offset it tracks in the process. A rotator that moves the file behind it breaks that offset, and nxdns does not notice until the next restart:

  • With copytruncate the offset survives the truncation, so the next line lands where it would have without it. The file regrows with a sparse, NUL-filled prefix as long as the log that was just rotated away.
  • With rename-and-create nxdns keeps writing to the renamed inode. The new file stays empty, the renamed one grows without bound, and logging.max_size_mb bounds nothing on disk.

If an external rotator has to own the file, set logging.output = .stderr and let the collector capture the stream instead.

The log file is the one path here nxdns does not set a mode on. config.db and querylog.db are chmodded to 0600 whatever the umask; the log file is left at whatever the umask gives it. Under the shipped unit that is 0600, because deploy/systemd/nxdns.service sets UMask=0077. Run from a shell with the usual umask 022 it is 0644, and LogsDirectory=nxdns leaves the directory at systemd's default 0755, so a log written there is readable by any local user.

With logging.output = .stderr or .syslog nxdns writes to stderr and creates no file; under systemd the journal captures it.

Certificate and key files

doh_server.cert_path / key_path and dot_server.cert_path / key_path, conventionally under /etc/nxdns. nxdns reads them, never writes or creates them. Both must be readable by the user nxdns runs as, and the key must belong to the certificate: nxdns check loads the pair and fails when it does not. The key should also be readable by its owner only, which check warns about when it is not. A watcher polls both files and swaps a renewed pair in without a restart. See enable DoH and DoT.