Gates / test-aarch64 (push) Successful in 6m45s
Gates / frontend (push) Successful in 51s
Gates / test (push) Successful in 1m37s
Gates / container (push) Failing after 7m31s
Gates / package (push) Failing after 15m14s
CI / gates (push) Failing after 24m27s
379 lines
17 KiB
Markdown
379 lines
17 KiB
Markdown
# CLI reference
|
|
|
|
```
|
|
nxdns <command> [options]
|
|
```
|
|
|
|
Six subcommands: `run`, `check`, `export`, `import`, `version`, `help`. Source
|
|
of truth: `src/cli.zig`.
|
|
|
|
Every flag takes both spellings, `--flag value` and `--flag=value`. An attached
|
|
value that is empty (`--config=`) is a missing value, not an empty path.
|
|
`--allow-delete` is boolean and takes no value at all, so `--allow-delete=1` is
|
|
not a spelling of any flag this program has. A flag is rejected by the
|
|
subcommand that has no use for it: `--web-dev` outside `run` is an unknown flag,
|
|
not a no-op.
|
|
|
|
## `run`
|
|
|
|
Serves DNS until SIGINT or SIGTERM.
|
|
|
|
| Flag | Meaning |
|
|
| --- | --- |
|
|
| `--data-dir DIR` | Data directory (default `/var/lib/nxdns`). Created at mode 0700 if missing. |
|
|
| `--config FILE` | Make FILE the sole source of configuration and reconcile the database onto it at every start. No default: without this flag the database is the configuration and no file is read. |
|
|
| `--web-dev DIR` | Serve the web interface from DIR instead of the embedded assets, with no cache headers. Development only. |
|
|
|
|
### Which authority the invocation selects
|
|
|
|
The presence of `--config` picks the authority, and nothing else does. There is
|
|
no default path, no probe of `/etc/nxdns`, and nothing recorded in the database:
|
|
a configuration file sitting at `/etc/nxdns/config.zon` that no flag names
|
|
changes nothing at all.
|
|
|
|
| Invocation | Authority | What a start does |
|
|
| --- | --- | --- |
|
|
| `nxdns run` | The database | Serves what `config.db` holds. Nothing reads a file. |
|
|
| `nxdns run --config FILE` | FILE | Reads and validates FILE, reconciles the database onto it, then serves. |
|
|
|
|
The first log line after the migrations names the mode, so a journal says which
|
|
authority was live:
|
|
|
|
```
|
|
info(nxdns): authority: database
|
|
info(nxdns): authority: file (/etc/nxdns/config.zon)
|
|
```
|
|
|
|
In file mode the reconcile prints what it changed before that line — per-table
|
|
inserted (`+`), updated (`~`) and deleted (`-`) counts, the settings keys whose
|
|
values changed, and any change to whether the admin password is set:
|
|
|
|
```
|
|
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +45 ~0 -0;
|
|
settings keys changed: dns.bind_ipv4 dns.port web.bind web.port …
|
|
web authentication is now enabled
|
|
```
|
|
|
|
A start whose file matches the database writes nothing and says so:
|
|
|
|
```
|
|
reconciled '/etc/nxdns/config.zon': no changes
|
|
```
|
|
|
|
Blocklist state is not declarative and survives every reconcile: a source whose
|
|
URL the file still names keeps its row id, its checksum, its counters and its
|
|
compiled `<id>.list` and `<id>.wild`, so a restart in file mode downloads
|
|
nothing. Editing a source's URL is a new identity — a new row, a new id, and a
|
|
fresh download.
|
|
|
|
### Failing to start in file mode
|
|
|
|
File mode fails closed. A file that is missing, unreadable, unparseable,
|
|
oversized or invalid stops the start; nxdns never falls back to the database,
|
|
because a fallback turns a deploy typo into a silently stale configuration.
|
|
|
|
```
|
|
FAIL /etc/nxdns/config.zon: no such file
|
|
nxdns run failed: ManagedConfigUnreadable
|
|
run `nxdns check` to see the configuration in full
|
|
```
|
|
|
|
That is exit 2, and `check --config` on the same path agrees. Only path-class
|
|
open failures map that way — the file is not there, or the process may not read
|
|
it. An open that fails for a reason a retry could clear, such as
|
|
file-descriptor exhaustion or an I/O error, is exit 1: the box is wrong, not the
|
|
configuration. See [exit codes](#exit-codes).
|
|
|
|
The exit-2 agreement covers `run --config` and `check --config`, which read the
|
|
managed file through one shared helper. It does **not** extend to `import`'s
|
|
positional argument: a missing file there is `import failed: FileNotFound`, exit
|
|
1. That is deliberate rather than an oversight — the managed file is a
|
|
declarative input an operator deploys, so its absence is a fact about the
|
|
configuration, while `import`'s argument is a path typed at a prompt, and a
|
|
mistyped path is a failed command rather than a verdict on anything.
|
|
|
|
Run `nxdns check --config FILE` before restarting anything that deploys a file.
|
|
It grades every declarative fault `run` would hit — read, parse, size,
|
|
validation — through the same code, which is what makes it a usable precondition
|
|
in an Ansible handler.
|
|
|
|
## `check`
|
|
|
|
Validates the configuration and probes the upstreams. Exit 0 when it found no
|
|
failures, 2 when it found one. It always reports every problem, not just the
|
|
first. What it checks, in order:
|
|
|
|
1. Which source to check (see [source selection](#source-selection)).
|
|
2. Full validation — the same rules `import` enforces.
|
|
3. For each enabled DoH/DoT listener: both PEM files are read and the key is
|
|
tested against the certificate, through the same `CertStore.init` the
|
|
listeners boot with. A file that is missing, unreadable, too large or
|
|
unparseable, and a key that does not belong to the certificate, are all FAIL.
|
|
The key's permissions are a separate finding: WARN when any group or other
|
|
bit is set, which does not change the exit code.
|
|
4. A live probe: one real A query for `example.com` through every enabled
|
|
upstream, driving the same pool and failover machinery the server uses, with
|
|
the same two deadlines: `upstream.attempt_timeout_ms` bounds one try against
|
|
one upstream, `upstream.total_timeout_ms` the whole probe. A FAIL line names
|
|
the upstream and the concrete cause recorded in its health. This probe leaves
|
|
the machine, so `check` needs network access to pass. It runs from the
|
|
command line but not from unit tests.
|
|
|
|
| Flag | Meaning |
|
|
| --- | --- |
|
|
| `--data-dir DIR` | Data directory to look for `config.db` in. |
|
|
| `--config FILE` | Grade this file instead of the database. |
|
|
|
|
### Failures and warnings
|
|
|
|
Every finding carries a severity. `FAIL` is a problem that sets exit 2. `WARN`
|
|
is legal configuration that is almost certainly not what was meant — a blocklist
|
|
source no group links to, a TLS key readable beyond its owner — and never
|
|
changes an exit code, because the service starts either way.
|
|
|
|
The last line is a summary, and it never contradicts the lines above it:
|
|
|
|
| What was found | Last line | Exit |
|
|
| --- | --- | --- |
|
|
| Nothing | `OK: no problems found` | 0 |
|
|
| Warnings only | `OK: no failures found, 2 warnings` — `1 warning` in the singular | 0 |
|
|
| At least one failure | No summary line; the FAIL lines are the report | 2 |
|
|
|
|
### Source selection
|
|
|
|
The flag decides, exactly as it does for `run`. There is no fallback and no
|
|
probing of a default path:
|
|
|
|
- `--config FILE`: grade that file, and never open the database.
|
|
- No `--config`: grade `<data-dir>/config.db`. It is opened immutable, so
|
|
`check` writes nothing to it; see
|
|
[what `check` does not do](#what-check-does-not-do).
|
|
|
|
So `nxdns check` and `nxdns check --config FILE` grade what the matching `run`
|
|
invocation would serve. That is what makes `check` a pre-restart gate rather
|
|
than an approximation of one.
|
|
|
|
A bare `check` on a box with no database says so and names both ways out of the
|
|
state, exit 2:
|
|
|
|
```
|
|
no config database at /var/lib/nxdns/config.db
|
|
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
|
|
```
|
|
|
|
The first line of output otherwise always names which source was checked. A file
|
|
larger than 4 MiB fails with `larger than 4194304 bytes`; a ZON syntax error is
|
|
reported with its line and column.
|
|
|
|
A named file that is missing or unreadable is a finding like any other, not an
|
|
I/O failure that escapes the run: `FAIL <path>: no such file` or
|
|
`FAIL <path>: not readable`, exit 2.
|
|
|
|
What `check --config` cannot see is the reconcile itself. It needs no database,
|
|
so faults that only a write can produce — a disk that is full, a lock held by a
|
|
restart that started first — are invisible to it. Those are runtime failures,
|
|
exit 1, and they are not verdicts on the file.
|
|
|
|
### What `check` does not do
|
|
|
|
`check` never writes to `config.db`. It opens the file immutable, which means
|
|
SQLite refuses every statement that would write and builds no write-ahead log,
|
|
so no `config.db-wal` and no `config.db-shm` appear beside it. It does not chmod
|
|
the file and it does not migrate the schema.
|
|
|
|
Two consequences are worth knowing before you read a FAIL line as damage:
|
|
|
|
- A database behind this binary's schema is reported, not upgraded. Start the
|
|
service to migrate it.
|
|
|
|
```
|
|
FAIL <db>: schema version <n>, this nxdns expects <m>; `nxdns run` migrates it, `check` will not
|
|
```
|
|
|
|
- A database with an unapplied write-ahead log cannot be graded without writing,
|
|
because the newest settings are in the log and the main file holds older ones.
|
|
`check` says so rather than reading the stale values:
|
|
|
|
```
|
|
FAIL <db>: uncheckpointed changes are waiting in <db>-wal, and reading without writing would answer from the older settings in the main file; `nxdns run` applies them. A running nxdns normally holds this log, which is the usual reason to see this line.
|
|
```
|
|
|
|
A running nxdns is the usual holder of that log, so this is a common answer
|
|
when checking a live server rather than a sign of damage. It depends on what
|
|
is in the log, not on whether a server is up: a configuration write that has
|
|
not been checkpointed puts bytes there, and a server that has only been
|
|
answering queries leaves `config.db-wal` empty and reads normally. Check an
|
|
`export` with `--config`, or stop the service first.
|
|
|
|
## `export`
|
|
|
|
Writes the configuration as ZON to stdout, or atomically at mode 0600 to
|
|
`--out FILE`. `--out` paths are relative to the shell's working directory, not
|
|
to the data directory. The output is canonical: every default emitted,
|
|
deterministic ordering, no timestamps, so `export` → `import` → `export` is
|
|
byte-identical.
|
|
|
|
`export` does not create the data directory; it fails if the directory is not
|
|
there. A directory that exists without a `config.db` is not that case: the
|
|
database is opened with create semantics, so an empty `config.db` is created and
|
|
migrated, and the export is of a default configuration.
|
|
|
|
The output is the same in both authority modes — nothing marks a file as
|
|
exported from a file-mode box. That is what lets `export` be the adoption tool:
|
|
the file you check is the file you deploy.
|
|
|
|
`web.password` is always written as `null` and `web.password_hash` carries the
|
|
stored value, so an export re-imports without anyone knowing the password. A
|
|
`null` password is not the same statement as an empty one: see
|
|
[`web.password` and `web.password_hash`](configuration.md#password-and-hash).
|
|
|
|
| Flag | Meaning |
|
|
| --- | --- |
|
|
| `--data-dir DIR` | Data directory holding `config.db`. |
|
|
| `--out FILE` | Write to FILE instead of stdout. |
|
|
|
|
See [back up and restore](../how-to/back-up-and-restore.md).
|
|
|
|
## `import FILE`
|
|
|
|
Converges the database onto FILE in one transaction — the same reconcile a
|
|
file-mode `run` performs, done once from the command line. Prints every
|
|
validation problem; a failed import leaves the database untouched. Creates the
|
|
data directory at mode 0700 if it is missing.
|
|
|
|
`FILE` is positional and may appear before or after the flags.
|
|
|
|
| Flag | Meaning |
|
|
| --- | --- |
|
|
| `--data-dir DIR` | Data directory holding `config.db` (created if missing). |
|
|
| `--allow-delete` | Apply a file whose diff deletes rows. |
|
|
|
|
**`import` is a stop-first operation.** It rewrites configuration underneath a
|
|
process that read it at startup, and a running server notices only some of it:
|
|
filtering picks up the imported rows at the next reload, while upstreams,
|
|
listeners and settings stay at their boot values until a restart.
|
|
|
|
### The delete gate
|
|
|
|
Rows the database holds and FILE does not name are deleted. That is the point of
|
|
a declarative apply, and it is also how a mistaken `nxdns import ./wrong.zon`
|
|
empties a configured server, so it takes a flag:
|
|
|
|
```
|
|
FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it
|
|
import failed: DestructiveImport
|
|
```
|
|
|
|
That is exit 2, and the transaction rolls back. The message names every table
|
|
with a non-zero delete count, so you can tell an intended pruning from a wrong
|
|
file before applying anything.
|
|
|
|
An import that only adds rows, or only edits them, needs no flag. "Edit" here
|
|
means a change to a row nxdns can still recognise as the same row. Each table
|
|
has one column, or one tuple, that establishes identity:
|
|
|
|
| Table | Identity |
|
|
| --- | --- |
|
|
| `blocklist_sources`, `upstreams` | `url` |
|
|
| `groups` | `name` |
|
|
| `clients` | `ip` |
|
|
| `client_prefixes` | `prefix` |
|
|
| `forward_zones` | `zone` |
|
|
| `local_records` | `(name, rtype, value)` |
|
|
| `rules` | `(group, pattern, kind, action)` |
|
|
|
|
Change anything else on a row — a source's name, a group's `safe_search`, a
|
|
client's group — and it is an edit, applied without a flag. Change the identity
|
|
itself, such as renaming a group or correcting a typo in an upstream URL, and
|
|
the engine sees a row that vanished and a row that appeared: that needs
|
|
`--allow-delete`.
|
|
|
|
### What survives an import
|
|
|
|
Runtime state is not declarative and is preserved by identity, not by luck. A
|
|
blocklist source whose URL is unchanged keeps its row id, its checksum, its
|
|
counters and its compiled files, so an import costs no downloads. Clients the
|
|
DNS path materialised from traffic are kept whole; naming one in the file
|
|
promotes that row in place, keeping its first-seen and last-seen. Observed
|
|
clients whose group the file no longer declares are moved to the `default`
|
|
group rather than deleted with it, and none of that ever trips the delete gate.
|
|
|
|
### `import` against a file-mode box
|
|
|
|
It behaves like any other import. Nothing in the database records that a file
|
|
governs it — authority lives in the invocation — so `import` neither detects nor
|
|
refuses that case. The next restart's reconcile converges the database back to
|
|
the file and its summary reports what it corrected. A source the import deleted
|
|
comes back with a new row id, which means a fresh download of the whole list.
|
|
|
|
If a restart and an import race for the write lock, one of them simply wins: both
|
|
take `BEGIN IMMEDIATE` under a 5-second busy timeout, so the outcome is an
|
|
ordering, never a corrupted database.
|
|
|
|
## `version`
|
|
|
|
Prints two lines: the nxdns version with the git commit, then the Zig version
|
|
the binary was built with. Takes no flags and no arguments.
|
|
|
|
## `help`
|
|
|
|
Prints the usage text to stdout and exits 0. `nxdns --help` and `nxdns -h` do
|
|
the same. A usage error prints the same text to stderr and exits 64.
|
|
|
|
## Exit codes
|
|
|
|
| Code | Meaning |
|
|
| --- | --- |
|
|
| 0 | Success. |
|
|
| 1 | Runtime failure — I/O, database, out of memory. A partial diagnostic report caused by an allocation failure is a runtime failure, not a verdict on the configuration. |
|
|
| 2 | A configuration problem the operator can fix, or a `check` that found one. |
|
|
| 64 | Usage error — unknown command or flag, a flag without its value, a missing or extra argument. |
|
|
|
|
Code 2 means the same thing from every subcommand. `src/config/faults.zig`
|
|
holds the one list of errors that mean "the configuration the operator supplied
|
|
is wrong", and `run`, `check` and `import` all ask it, so a rejected file exits
|
|
2 whichever command read it. The list is every error the validator raises, plus
|
|
`ParseZon`, `ConfigTooLarge`, `NoUsableUpstreams`, `BadCertificate` and
|
|
`ManagedConfigUnreadable`. In practice that covers a file with a syntax error,
|
|
one larger than 4 MiB, one with no `default` group (`MissingDefaultGroup`), one
|
|
with no enabled upstream (`NoUpstreams`), a bad bind address, a bad rate limit,
|
|
an unusable certificate, `password` and `password_hash` set together, and a
|
|
`--config` path that is absent or unreadable.
|
|
|
|
The last of those is the one deliberate seam. A file nxdns cannot open is a
|
|
configuration fault only when the *path* is the problem — the file is missing,
|
|
permissions deny it, a path component is not a directory. Every other open
|
|
failure, such as running out of file descriptors, is exit 1. The distinction
|
|
earns its keep under the shipped systemd unit, which stops the service on exit 2
|
|
rather than restarting it: a transient box fault graded as a configuration fault
|
|
would take the resolver down until someone noticed.
|
|
|
|
When `run` exits 2 it points at the diagnosis on stderr, whichever authority the
|
|
fault came from:
|
|
|
|
```
|
|
run `nxdns check` to see the configuration in full
|
|
```
|
|
|
|
On a box with no configuration at all, `run` and `check` add the line that names
|
|
both ways to get one:
|
|
|
|
```
|
|
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
|
|
```
|
|
|
|
`check` exits 2 for those faults and also when a probed upstream failed, when a
|
|
named configuration file is missing or unreadable, and when the database cannot
|
|
be read, is absent, or is not at this binary's schema version. Warnings never
|
|
contribute.
|
|
|
|
`import` exits 2 for those faults and for `DestructiveImport`. That last one is
|
|
deliberately not a configuration fault — it reports what applying the file would
|
|
delete, rather than anything wrong with its content — and `import` decides it
|
|
for itself; the answer to it is `--allow-delete`, not an edit.
|
|
|
|
`OutOfMemory` is exit 1 even when problems were recorded, because the report is
|
|
then incomplete. Every other error is 1.
|
|
|
|
Where an exit code sends you next: [troubleshoot](../how-to/troubleshoot.md).
|