612 lines
31 KiB
Markdown
612 lines
31 KiB
Markdown
# Configuration reference
|
||
|
||
Every section, field and collection nxdns accepts, with its type, default,
|
||
unit, validation rule and the subsystem that consumes it.
|
||
|
||
Source of truth: `src/config/model.zig` (the model and the defaults),
|
||
`src/config/validate.zig` (the rules), `src/config/{loader,reconcile,import,export}.zig`
|
||
(the lifecycle).
|
||
|
||
For how the file, the database and `export`/`import` relate to each other, see
|
||
[the configuration model](../explanation/configuration-model.md). For the
|
||
commands that read and write configuration, see [the CLI
|
||
reference](cli.md).
|
||
|
||
## File format
|
||
|
||
The file is ZON: a top-level anonymous struct whose fields are the sections and
|
||
collections below. Enum values are ZON enum literals (`.level = .err`,
|
||
`.response = .nxdomain`). Strings are double-quoted. The file may be at most
|
||
4 MiB (`max_config_bytes` in `src/config/loader.zig`); beyond that the error is
|
||
`ConfigTooLarge`. A syntax error is reported with its line and column.
|
||
|
||
Absent fields keep their defaults, both in the file and in the database. A
|
||
settings key stored in the database that the running binary does not know is
|
||
warned about and ignored, never an error.
|
||
|
||
### The `logging.level = .err` quirk
|
||
|
||
The log level `error` is a Zig keyword, so the ZON and model tag is `.err` while
|
||
the database and the settings API store the operator-facing word `"error"`. The
|
||
file says `.err`; `GET /api/settings` says `"error"`. `"err"` is not accepted as
|
||
database text, and `.error` is not a ZON tag.
|
||
|
||
## What is not in the file
|
||
|
||
Storage paths are process arguments, not configuration:
|
||
|
||
| Argument | Default | Meaning |
|
||
| --- | --- | --- |
|
||
| `--data-dir DIR` | `/var/lib/nxdns` | Holds `config.db` and `querylog.db`; see [files and directories](files-and-directories.md). |
|
||
| `--config FILE` | — | On `run`, makes FILE the sole source of configuration; on `check`, grades FILE instead of the database. No default: without it the database is the configuration. |
|
||
| `--web-dev DIR` | — | `run` only; serves the web interface from a directory instead of the embedded assets. |
|
||
|
||
## Scalar sections
|
||
|
||
The "Key" column is the settings key as stored in the database
|
||
(`section.field`); in the file the same field lives inside its section block,
|
||
for example `.dns = .{ .port = 53 }`.
|
||
|
||
### upstream
|
||
|
||
Timeouts for talking to upstream resolvers.
|
||
|
||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||
|---|---|---|---|---|---|
|
||
| `upstream.attempt_timeout_ms` | u32 | 2500 | ms | 100–120000, and not above `total_timeout_ms` | deadline on one attempt against one upstream inside the pool's failover loop (`src/upstream/pool.zig`), the whole attempt including the connect |
|
||
| `upstream.read_timeout_ms` | u32 | 3000 | ms | 100–120000 | read deadline on conditional-forward-zone exchanges (`src/local/forward_client.zig`) |
|
||
| `upstream.total_timeout_ms` | u32 | 5000 | ms | 100–120000 | per-query budget of the upstream pool (`src/upstream/pool.zig`): every failover attempt together, not one of them; also the `nxdns check` probe deadline |
|
||
|
||
The two pool budgets nest. `attempt_timeout_ms` bounds one try against one
|
||
upstream; when it expires the pool records the failure and moves to the next
|
||
candidate. `total_timeout_ms` bounds the whole loop, so a query against five
|
||
unreachable upstreams costs the total budget once, not five attempt budgets in
|
||
a row. When the total expires the in-flight attempt is canceled and the query
|
||
fails with a timeout.
|
||
|
||
`read_timeout_ms` is unrelated to both. It bounds a different subsystem — the
|
||
conditional-forward-zone client — so no cross-check relates it to the pool's
|
||
budgets, and it is free to sit above either of them.
|
||
|
||
### dns
|
||
|
||
The plain DNS listener (UDP and TCP).
|
||
|
||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||
|---|---|---|---|---|---|
|
||
| `dns.bind_ipv4` | string | `"0.0.0.0"` | IP address | must parse as an IPv4 literal | UDP/TCP listener bind (`src/app.zig`) |
|
||
| `dns.bind_ipv6` | string | `"::"` | IP address | must parse as an IPv6 literal | UDP/TCP listener bind (`src/app.zig`) |
|
||
| `dns.port` | u16 | 53 | port | 1–65535 (0 is refused) | UDP/TCP listener port |
|
||
| `dns.rate_limit` | u32 | 1000 | queries per window | at least 1 | per-client DNS rate limiter (`src/server/rate_limiter.zig`) |
|
||
| `dns.rate_window_seconds` | u32 | 60 | seconds | 1–3600 | window of the same limiter |
|
||
|
||
`dns.bind_ipv4` and `dns.bind_ipv6` each name one socket of the dual-stack pair,
|
||
so each is required to be a literal of its own family. An IPv4 wildcard in
|
||
`dns.bind_ipv6` is refused: it would bind IPv4 as the "v6" socket and make the
|
||
real IPv4 bind fail with `AddressInUse`, silently removing the IPv6 service.
|
||
|
||
### blocking
|
||
|
||
What a blocked query gets back.
|
||
|
||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||
|---|---|---|---|---|---|
|
||
| `blocking.response` | enum `.zero` \| `.nxdomain` | `.zero` | — | one of the two tags | blocked-response synthesis (`src/filter/response.zig`): `.zero` answers 0.0.0.0 / `::`, `.nxdomain` answers NXDOMAIN |
|
||
| `blocking.ttl` | u32 | 5 | seconds | at most 86400; 0 allowed | TTL on the synthesized block answer |
|
||
|
||
### cache
|
||
|
||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||
|---|---|---|---|---|---|
|
||
| `cache.size` | u32 | 10000 | entries | 1–1000000 | DNS answer cache capacity (`src/cache/dns_cache.zig`) |
|
||
| `cache.negative_ttl_max` | u32 | 3600 | seconds | at most 86400 | cap on cached negative answers; 0 disables negative caching |
|
||
|
||
The cache's slot array is allocated in full at startup, so `cache.size` carries
|
||
a ceiling: it is a sanity bound against a typo, not a promise that the value
|
||
fits in the box's memory. There is no "off" value — to run without a cache, set
|
||
`cache.size` to 1.
|
||
|
||
### web
|
||
|
||
The web interface and REST API.
|
||
|
||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||
|---|---|---|---|---|---|
|
||
| `web.enabled` | bool | true | — | — | gates the whole web stack: server, sessions, SSE hub, API limiter (`src/app.zig`) |
|
||
| `web.bind` | string | `"0.0.0.0"` | IP address | must parse as an IP address of either family | web listener bind (`src/web/server.zig`) |
|
||
| `web.port` | u16 | 8080 | port | 1–65535 (0 is refused) | web listener port |
|
||
| `web.password` | optional string | absent | — | must not be set together with `web.password_hash`; the empty string is refused | operator input only; hashed and discarded. Never a settings row — see [Password and hash](#password-and-hash) |
|
||
| `web.password_hash` | optional string | absent | — | — | argon2id PHC string verified at login (`src/web/auth.zig`); `""` disables authentication, absent keeps the stored hash |
|
||
| `web.session_ttl_hours` | u16 | 24 | hours | at least 1 | session expiry and cookie `Max-Age` (`src/web/auth.zig`) |
|
||
| `web.api_rate_limit_per_min` | u32 | 300 | requests per minute | at least 1 | API token-bucket limiter (`src/web/api_limiter.zig`) |
|
||
| `web.api_localhost_exempt` | bool | true | — | — | loopback requests skip the API limiter |
|
||
| `web.sse_max_connections_per_ip` | u16 | 3 | connections | at least 1 | cap on concurrent SSE streams per client address |
|
||
| `web.trusted_proxies` | string | `""` | — | comma-separated IP literals | reverse proxies whose `X-Forwarded-For` nxdns believes (`src/web/server.zig`); empty trusts none |
|
||
|
||
### doh_server
|
||
|
||
The DNS-over-HTTPS listener (server side, for clients on the LAN). See
|
||
[enable DoH and DoT](../how-to/enable-doh-and-dot.md).
|
||
|
||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||
|---|---|---|---|---|---|
|
||
| `doh_server.enabled` | bool | false | — | — | gates the DoH listener (`src/server/doh_server.zig`) |
|
||
| `doh_server.bind` | string | `"0.0.0.0"` | IP address | must parse as an IP address of either family | DoH listener bind |
|
||
| `doh_server.port` | u16 | 443 | port | 1–65535 (0 is refused, enabled or not) | DoH listener port |
|
||
| `doh_server.cert_path` | string | `"/etc/nxdns/cert.pem"` | path | non-empty when enabled | certificate loaded into the hot-reloading `CertStore`; the file itself is loaded and paired with the key by `nxdns check`, not by the validator |
|
||
| `doh_server.key_path` | string | `"/etc/nxdns/key.pem"` | path | non-empty when enabled | private key for the same; `nxdns check` warns when it is readable beyond its owner |
|
||
|
||
### dot_server
|
||
|
||
The DNS-over-TLS listener. Same shape as `doh_server`; only the default port
|
||
differs.
|
||
|
||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||
|---|---|---|---|---|---|
|
||
| `dot_server.enabled` | bool | false | — | — | gates the DoT listener (`src/server/dot_server.zig`) |
|
||
| `dot_server.bind` | string | `"0.0.0.0"` | IP address | must parse as an IP address of either family | DoT listener bind |
|
||
| `dot_server.port` | u16 | 853 | port | 1–65535 (0 is refused, enabled or not) | DoT listener port |
|
||
| `dot_server.cert_path` | string | `"/etc/nxdns/cert.pem"` | path | non-empty when enabled | certificate, shared `CertStore` with hot reload |
|
||
| `dot_server.key_path` | string | `"/etc/nxdns/key.pem"` | path | non-empty when enabled | private key for the same |
|
||
|
||
### edns
|
||
|
||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||
|---|---|---|---|---|---|
|
||
| `edns.ecs_mode` | enum `.strip` \| `.forward` | `.strip` | — | one of the two tags | EDNS Client Subnet handling in the query path (`src/server/handler.zig`, `src/dns/edns.zig`): `.strip` removes the client subnet before forwarding, `.forward` passes it through |
|
||
|
||
### logging
|
||
|
||
Process log and query log behavior.
|
||
|
||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||
|---|---|---|---|---|---|
|
||
| `logging.level` | enum `.err` \| `.warn` \| `.info` \| `.debug` | `.info` | — | one of the four tags; stored as `"error"` / `"warn"` / `"info"` / `"debug"` | log threshold (`src/platform/logging.zig`) |
|
||
| `logging.retention_days` | u16 | 30 | days | at least 1 | query-log pruning cutoff (`src/storage/retention.zig`) and the client tracker's last-seen cutoff (`src/server/clients.zig`) |
|
||
| `logging.query_log_buffer_max` | u32 | 10000 | entries | 1–1000000 | in-memory query-log ring size and backpressure cap (`src/storage/logger.zig`) |
|
||
| `logging.hide_domains` | bool | false | — | — | the query log stores a hidden marker instead of the domain |
|
||
| `logging.hide_client_ips` | bool | false | — | — | the query log stores a hidden marker instead of the client address |
|
||
| `logging.output` | enum `.stderr` \| `.syslog` \| `.file` | `.stderr` | — | one of the three tags | log sink selection (`src/platform/logging.zig`); `.stderr` and `.syslog` both write to stderr (journald captures it), `.file` rotates |
|
||
| `logging.file_path` | string | `"/var/log/nxdns/nxdns.log"` | path | when `output` is `.file`: non-empty and starting with `/` | rotating log file; its directory also feeds the disk monitor. The binary does not create the directory |
|
||
| `logging.max_size_mb` | u32 | 50 | MiB | at least 1 | rotation trigger for the log file |
|
||
| `logging.max_files` | u8 | 5 | files | at least 1 | log files kept in total, the live one included, so the highest rotated generation is `max_files - 1`; the default 5 keeps `nxdns.log` plus `nxdns.log.1` through `nxdns.log.4`, and a value of 1 keeps only the live file, which rotation deletes rather than renames |
|
||
|
||
### disk
|
||
|
||
Free-space thresholds for the data directory. Below them the query-log writer,
|
||
the client tracker and the blocklist scheduler are throttled
|
||
(`src/storage/disk_monitor.zig`); DNS resolution is never gated.
|
||
|
||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||
|---|---|---|---|---|---|
|
||
| `disk.min_free_mb` | u32 | 200 | MiB | at least 1, and not above `warn_free_mb` | `critical` threshold |
|
||
| `disk.warn_free_mb` | u32 | 500 | MiB | at least 1 | `warn` threshold |
|
||
|
||
### blocklist_update
|
||
|
||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||
|---|---|---|---|---|---|
|
||
| `blocklist_update.enabled` | bool | true | — | — | blocklist refresh scheduler (`src/filter/manager.zig`); when false the scheduler stops after the startup pass and only a manual refresh runs |
|
||
| `blocklist_update.interval_hours` | u16 | 24 | hours | at least 1 | sleep between refresh passes and the per-source staleness test; each pass is preceded by the [orphan sweep](files-and-directories.md#the-orphan-sweep), which runs even when the pass itself is skipped for disk space |
|
||
|
||
## Collections
|
||
|
||
Collections are ZON lists of structs. Fields without a default are required.
|
||
Runtime columns (first/last seen timestamps, per-source download counters) are
|
||
deliberately not part of the model: import sets timestamps to the import time
|
||
and export omits them, which is what keeps the round trip byte-stable.
|
||
|
||
### groups
|
||
|
||
Client groups. A group named `default` is required; every client not assigned
|
||
elsewhere lands in it, and import guarantees it keeps database id 1.
|
||
|
||
| Field | Type | Default | Validation |
|
||
|---|---|---|---|
|
||
| `name` | string | required | non-empty, unique |
|
||
| `safe_search` | bool | false | — |
|
||
|
||
Consumed by the filter engine (`src/filter/matcher.zig`); `safe_search`
|
||
triggers the safe-search rewrite in the query path.
|
||
|
||
### upstreams
|
||
|
||
Upstream resolvers. At least one enabled upstream is required.
|
||
|
||
| Field | Type | Default | Validation |
|
||
|---|---|---|---|
|
||
| `url` | string | required | an `https://` (DoH) or `tls://` (DoT) endpoint accepted by `transport.Endpoint.parse`; unique; a `tls://` host must be an IP literal |
|
||
| `priority` | i32 | 100 | — (lower is tried first) |
|
||
| `enabled` | bool | true | — |
|
||
| `tls_name` | string | `""` | DoT only — a `tls_name` on an `https://` upstream is an error; when set it must be a valid domain name |
|
||
|
||
Consumed by the upstream pool (`src/upstream/pool.zig`): entries are sorted by
|
||
ascending priority and tried in order with failover. `tls_name` sets SNI and the
|
||
certificate verification name for a `tls://` upstream written as an IP literal;
|
||
empty means "verify by the URL host" (`src/upstream/dot_client.zig`).
|
||
|
||
A `tls://` upstream's host must be an IP literal. nxdns does not resolve an
|
||
upstream's own name — that is a bootstrap problem, and the DoT client refuses a
|
||
non-literal host on every dial — so the validator rejects the hostname form
|
||
rather than letting it fail at query time as an unreachable upstream. Write the
|
||
address and put the name in `tls_name`:
|
||
|
||
```zig
|
||
.{ .url = "tls://9.9.9.9:853", .tls_name = "dns.quad9.net" },
|
||
```
|
||
|
||
A DoH upstream is not affected: `https://` goes through the HTTP client, which
|
||
resolves normally, so a hostname there is the usual form.
|
||
|
||
### clients
|
||
|
||
Known clients with a fixed group assignment.
|
||
|
||
| Field | Type | Default | Validation |
|
||
|---|---|---|---|
|
||
| `ip` | string | required | an IP address; unique after canonicalization (`FD00::1` and `fd00:0:0:0:0:0:0:1` collide) |
|
||
| `name` | string | `""` | — (display only, never read by the resolver) |
|
||
| `group` | string | `"default"` | must name a declared group |
|
||
|
||
Consumed by the filter engine's exact address-to-group lookup
|
||
(`src/filter/matcher.zig`).
|
||
|
||
### client_prefixes
|
||
|
||
Group assignment by CIDR prefix, for clients without an exact entry.
|
||
|
||
| Field | Type | Default | Validation |
|
||
|---|---|---|---|
|
||
| `prefix` | string | required | a CIDR prefix (`192.168.2.0/24`, `fd00:abcd::/48`); unique after canonicalization |
|
||
| `group` | string | `"default"` | must name a declared group |
|
||
| `priority` | i32 | 100 | — (ties on match are broken by lower priority) |
|
||
|
||
Consumed by the filter engine's longest-prefix match
|
||
(`src/filter/matcher.zig`).
|
||
|
||
### blocklist_sources
|
||
|
||
Downloadable blocklists.
|
||
|
||
| Field | Type | Default | Validation |
|
||
|---|---|---|---|
|
||
| `url` | string | required | an `http://` or `https://` URL with a host; unique |
|
||
| `name` | string | required | non-empty |
|
||
| `enabled` | bool | true | — |
|
||
| `is_suggested` | bool | false | — (web UI hint only, never read by the resolver) |
|
||
|
||
Consumed by the blocklist manager (`src/filter/manager.zig`): downloaded by the
|
||
fetcher and compiled into domain sets. A disabled source is neither downloaded
|
||
nor loaded.
|
||
|
||
A list in Adblock Plus syntax may also carry exception lines, `@@||name^` and
|
||
`@@||name`, either of which may end in `$important`. Those become allow entries
|
||
that cancel what any attached list blocks, for the name and its subdomains. They
|
||
cancel nothing an operator decided: every rule of the table above is checked
|
||
first, so a downloaded list can reopen only a hole another downloaded list dug.
|
||
Each source reports how many it carried as `exceptions`; there is no way to write
|
||
one by hand, and no reason to want one — write an allow rule instead.
|
||
|
||
### group_sources
|
||
|
||
Which groups consult which blocklist sources.
|
||
|
||
| Field | Type | Default | Validation |
|
||
|---|---|---|---|
|
||
| `group` | string | required | must name a declared group |
|
||
| `source_url` | string | required | must name a declared blocklist source's `url`; the (group, source_url) pair is unique |
|
||
|
||
Consumed by the filter engine when assembling each group's compiled domain sets
|
||
(`src/filter/matcher.zig`). A link to a disabled source is skipped.
|
||
|
||
### rules
|
||
|
||
Per-group allow and block overrides, checked before the blocklists.
|
||
|
||
| Field | Type | Default | Validation |
|
||
|---|---|---|---|
|
||
| `group` | string | required | must name a declared group |
|
||
| `pattern` | string | required | see below |
|
||
| `kind` | enum `.exact` \| `.wildcard` \| `.regex` | required | — |
|
||
| `action` | enum `.allow` \| `.block` | required | — |
|
||
|
||
Pattern rules: an `.exact` pattern is a plain domain name and may not contain
|
||
`*`. A `.wildcard` pattern must contain at least one label that is exactly `*`
|
||
(`*.tracker.example`, or `*` alone), and every other label must be a legal DNS
|
||
label. `ads*.example` is not a valid wildcard; a partial label is what the
|
||
`.regex` kind is for.
|
||
|
||
A `.regex` pattern is a regular expression matched against the whole normalized
|
||
lowercase name, unanchored unless you write `^` or `$` — the POSIX-grep
|
||
convention. It is stored exactly as you typed it, which the other two kinds are
|
||
not: lowercasing would turn `\D` into `\d`, and trimming a trailing `.` would
|
||
delete an any-byte atom. The engine (`src/filter/regex.zig`) accepts literal
|
||
bytes, `.` for any byte, character classes `[a-z0-9]` with a leading `^` for
|
||
negation, the escapes `\d` and `\w` plus `\` before any other ASCII punctuation
|
||
to make it a literal, the repetitions `*` `+` `?` `{n}` `{n,m}` `{n,}`,
|
||
alternation `|`, grouping `(...)`, and the anchors `^` and `$`.
|
||
|
||
Everything else is refused at the edge rather than approximated, so a pattern
|
||
written for another engine fails where you can read the diagnostic instead of
|
||
silently matching names you did not mean:
|
||
|
||
- backreferences, lookaround, captures, named groups, Unicode classes and the
|
||
`(?…)` prefix they share;
|
||
- any alphanumeric escape the list above omits — `\s`, `\b`, `\1`, `\D`;
|
||
- a `]` inside a class, unless written `\]`;
|
||
- an empty pattern, and an empty branch: `ads|` is refused rather than read as a
|
||
pattern that matches every name;
|
||
- a quantifier applied straight to another quantifier: `a+?` is refused rather
|
||
than read as `(a+)?`, which matches every name. Write `(a+)?` to mean that.
|
||
|
||
A pattern is at most 256 bytes and compiles to at most 1024 instructions, each
|
||
limit with its own diagnostic, and one group holds at most 256 regex rules.
|
||
Groups do not capture, and the engine simulates every alternative in lockstep,
|
||
so a pattern costs at most its compiled length times the length of the name —
|
||
`(a+)+b` is as cheap here as it is expensive in a backtracking engine.
|
||
|
||
Consumed by the filter engine's rule sets (`src/filter/rules.zig`), which checks
|
||
the three kinds in the order they are listed above, allow before block within
|
||
each. Regex is checked last of the three because it is the only kind that costs
|
||
more than a hash lookup or a label walk.
|
||
|
||
### local_records
|
||
|
||
Local DNS answers, served without touching any upstream.
|
||
|
||
| Field | Type | Default | Validation |
|
||
|---|---|---|---|
|
||
| `name` | string | required | a valid domain name |
|
||
| `rtype` | enum `.a` \| `.aaaa` \| `.cname` | required | stored as `A` / `AAAA` / `CNAME` |
|
||
| `value` | string | required | an IPv4 address for `.a`, an IPv6 address for `.aaaa`, a domain name for `.cname` |
|
||
| `ttl` | u32 | 300 | 1–604800 seconds |
|
||
|
||
The (name, rtype, value) triple is unique. Consumed by the local records table
|
||
in the query path (`src/local/records.zig`).
|
||
|
||
### forward_zones
|
||
|
||
Zones resolved by a specific resolver instead of the configured upstreams, for
|
||
LAN or corporate domains.
|
||
|
||
| Field | Type | Default | Validation |
|
||
|---|---|---|---|
|
||
| `zone` | string | required | a valid domain name; unique |
|
||
| `resolver` | string | required | `udp://IP:port` or `tcp://IP:port`; the host must be an IP literal and the port is mandatory |
|
||
|
||
The resolver host must be an IP literal because resolving the resolver's own
|
||
name would be a bootstrap problem. Matching is longest suffix
|
||
(`src/local/forward_zones.zig`); the exchange is UDP then TCP
|
||
(`src/local/forward_client.zig`) with `upstream.read_timeout_ms` as the read
|
||
deadline.
|
||
|
||
## Password and hash
|
||
|
||
Both fields are optional, and the difference between *absent* and *empty* is the
|
||
whole design. Absent means "keep whatever is stored". Empty means "there is no
|
||
password".
|
||
|
||
| The file says | What happens to the stored hash |
|
||
| --- | --- |
|
||
| Neither field | Untouched. Authentication stays exactly as it was. |
|
||
| `.password = "some-password"` | Verified against the stored hash; kept when it matches, replaced with a fresh hash when it does not. |
|
||
| `.password = ""` | Refused, with a diagnostic naming the remedy. |
|
||
| `.password_hash = "$argon2id$…"` | Written verbatim. |
|
||
| `.password_hash = ""` | Cleared, which disables authentication. |
|
||
| Both fields | Refused (`PasswordAndHashBothSet` — ambiguity in a security setting). |
|
||
|
||
Silence has to mean "keep", because the alternative is a trap. An operator who
|
||
exports a configuration and trims the long PHC string out of it before
|
||
committing the file to git means "leave the password alone", not "open the admin
|
||
interface to the LAN". So disabling authentication takes the explicit empty
|
||
string, and the empty *plaintext* — which would otherwise hash into a real hash
|
||
that no login can ever satisfy — is refused outright:
|
||
|
||
```
|
||
FAIL web.password: password is set to the empty string; omit the field to keep the stored password, or set password_hash = "" to disable authentication
|
||
```
|
||
|
||
- `web.password` is operator input only. It is hashed with argon2id (OWASP
|
||
parameters: t=2, m=19 MiB, p=1, PHC encoding) into `web.password_hash` and
|
||
discarded. There is no `web.password` settings row, and `nxdns export` always
|
||
writes `.password = null`.
|
||
- `web.password_hash` is the stored argon2id PHC string. Supplying it directly,
|
||
for example from a previous export, is how a backup restores authentication
|
||
without knowing the password.
|
||
|
||
A plaintext password that has not changed is verified rather than re-hashed, so
|
||
applying the same file twice leaves the same bytes in the database. That is what
|
||
keeps the export/import round trip byte-stable with a password in the file. It
|
||
costs a full argon2id computation either way — the verification is not a
|
||
shortcut, and caching the plaintext to skip it would be a security bug.
|
||
|
||
Any change to whether a password is set is announced at startup, never left as a
|
||
count:
|
||
|
||
```
|
||
web authentication is now enabled
|
||
web authentication is now disabled
|
||
```
|
||
|
||
See [set up admin
|
||
authentication](../how-to/set-up-admin-authentication.md).
|
||
|
||
## Validation errors
|
||
|
||
`nxdns check`, `nxdns import` and a `nxdns run --config` that reads the file
|
||
all print one `FAIL path: message` line per problem, and report every
|
||
problem rather than the first. A finding that is legal but almost certainly
|
||
unintended is prefixed `WARN` instead: it does not change the exit code, and it
|
||
is printed by all three even when nothing failed, so an accepted configuration
|
||
still says what is odd about it.
|
||
|
||
A url in a diagnostic is redacted to its scheme, host and port. The userinfo,
|
||
the path, the query and the fragment are dropped, and control characters are
|
||
escaped. These lines reach the journal, and every one of those parts can carry a
|
||
credential: a NextDNS DoH upstream is `https://dns.nextdns.io/abcd12`, where the
|
||
path segment is the whole account identifier. The field path beside the message
|
||
names the entry, so `blocklist_sources[1].url` still says which one to go and
|
||
fix.
|
||
|
||
What redaction cannot remove is the host, because a hostname is not a secret in
|
||
the general case — it is resolved publicly and offered as SNI on every
|
||
connection — and dropping it would leave a diagnostic that names nothing worth
|
||
reading. A vendor that puts an account identifier in the hostname therefore has
|
||
that identifier appear in any line naming the entry. NextDNS is the example:
|
||
`abcd12.dns.nextdns.io` is a profile id.
|
||
|
||
Two things limit the exposure. A hostname `tls://` upstream is rejected by the
|
||
validator (see `upstreams` above), so the DoT form of that URL can only reach
|
||
the log once, in the line rejecting it — never on every failover. And NextDNS
|
||
publishes a DoH endpoint, `https://dns.nextdns.io/abcd12`, whose identifier sits
|
||
in the path and is redacted in full; configure that form and nothing identifying
|
||
reaches the log at all.
|
||
|
||
The error set is `validate.ValidateError` in `src/config/validate.zig`:
|
||
|
||
| Error | Raised by |
|
||
| --- | --- |
|
||
| `NoUpstreams` | no upstream has `enabled = true` |
|
||
| `BadUpstreamUrl` | an upstream `url` is not an `https://` or `tls://` endpoint |
|
||
| `UpstreamHostNotIpLiteral` | a `tls://` upstream `url` names a host instead of an IP literal |
|
||
| `DuplicateUpstreamUrl` | two upstreams share a `url` |
|
||
| `BadTlsName` | a `tls_name` is not a valid domain name |
|
||
| `TlsNameOnNonTlsUpstream` | a `tls_name` on a non-`tls://` upstream |
|
||
| `MissingDefaultGroup` | no group is named `default` |
|
||
| `DuplicateGroupName` | two groups share a `name` |
|
||
| `EmptyGroupName` | a group `name` is empty |
|
||
| `UnknownGroup` | a client, prefix, group source or rule names a group that is not declared |
|
||
| `BadClientIp` / `DuplicateClientIp` | a client `ip` is unparseable, or collides after canonicalization |
|
||
| `BadClientPrefix` / `DuplicateClientPrefix` | the same for a `client_prefixes.prefix` |
|
||
| `BadSourceUrl` / `DuplicateSourceUrl` / `EmptySourceName` | blocklist source fields |
|
||
| `UnknownSource` / `DuplicateGroupSource` | `group_sources` links |
|
||
| `BadRulePattern` | a rule `pattern` does not match its `kind` |
|
||
| `BadLocalRecordName` / `BadLocalRecordValue` / `DuplicateLocalRecord` | local record fields |
|
||
| `BadForwardZone` / `DuplicateForwardZone` / `BadResolverUrl` | forward zone fields |
|
||
| `BadPort` | a port field is 0 |
|
||
| `BadTimeout` | a timeout is outside 100–120000 ms, or `attempt` is above `total` |
|
||
| `BadTtl` | `blocking.ttl`, `cache.negative_ttl_max`, a record `ttl`, `web.session_ttl_hours` or `blocklist_update.interval_hours` outside its range |
|
||
| `BadCacheSize` | `cache.size` outside 1–1000000 |
|
||
| `BadRetention` | `logging.retention_days` below 1, or `logging.query_log_buffer_max` outside 1–1000000 |
|
||
| `BadLogRotation` | `logging.max_size_mb` or `logging.max_files` below 1 |
|
||
| `BadDiskThresholds` | a threshold below 1, or `min_free_mb` above `warn_free_mb` |
|
||
| `BadRateLimit` | `dns.rate_limit`, `dns.rate_window_seconds`, `web.api_rate_limit_per_min` or `web.sse_max_connections_per_ip` out of range |
|
||
| `BadBindAddress` | a bind field is not an IP address, or not of the required family |
|
||
| `BadTrustedProxy` | an element of `web.trusted_proxies` is not an IP literal |
|
||
| `MissingCertPath` / `MissingKeyPath` | an enabled TLS listener with an empty path |
|
||
| `MissingLogPath` | `logging.output = .file` with an empty or relative `file_path` |
|
||
| `PasswordAndHashBothSet` | both `web.password` and `web.password_hash` are set |
|
||
|
||
Warnings are a separate set, outside `ValidateError` because they are not
|
||
failures. There is one:
|
||
|
||
| Warning | Raised by |
|
||
| --- | --- |
|
||
| `SourceInNoGroup` | a `blocklist_sources` entry that no `group_sources` link names; it downloads and blocks nothing until a group uses it |
|
||
|
||
## Minimal working example
|
||
|
||
The smallest file that passes validation: a `default` group and one enabled
|
||
upstream. Everything else keeps its default.
|
||
|
||
```zon
|
||
.{
|
||
.groups = .{ .{ .name = "default" } },
|
||
.upstreams = .{ .{ .url = "https://dns.quad9.net/dns-query" } },
|
||
}
|
||
```
|
||
|
||
## Fuller annotated example
|
||
|
||
```zon
|
||
.{
|
||
// Plain DNS on the standard port, rate-limited per client.
|
||
.dns = .{
|
||
.bind_ipv4 = "0.0.0.0",
|
||
.bind_ipv6 = "::",
|
||
.port = 53,
|
||
.rate_limit = 1000,
|
||
.rate_window_seconds = 60,
|
||
},
|
||
|
||
// Blocked queries answer 0.0.0.0 / :: with a 5 second TTL.
|
||
.blocking = .{ .response = .zero, .ttl = 5 },
|
||
|
||
.cache = .{ .size = 10000, .negative_ttl_max = 3600 },
|
||
|
||
// Web UI on 8080. The password is hashed and never stored as plaintext;
|
||
// leave .password_hash out when setting .password (they are exclusive).
|
||
.web = .{
|
||
.enabled = true,
|
||
.port = 8080,
|
||
.password = "correct horse battery staple",
|
||
.session_ttl_hours = 24,
|
||
},
|
||
|
||
// Serve DoT to the LAN. The key file should be mode 0600.
|
||
.dot_server = .{
|
||
.enabled = true,
|
||
.port = 853,
|
||
.cert_path = "/etc/nxdns/cert.pem",
|
||
.key_path = "/etc/nxdns/key.pem",
|
||
},
|
||
|
||
// Strip EDNS Client Subnet before forwarding (the default).
|
||
.edns = .{ .ecs_mode = .strip },
|
||
|
||
// ".err" in the file; the settings API shows it as "error".
|
||
.logging = .{ .level = .err, .retention_days = 14 },
|
||
|
||
.blocklist_update = .{ .enabled = true, .interval_hours = 24 },
|
||
|
||
// "default" is mandatory. Additional groups get their own rules,
|
||
// blocklists and safe-search flag.
|
||
.groups = .{
|
||
.{ .name = "default" },
|
||
.{ .name = "kids", .safe_search = true },
|
||
},
|
||
|
||
// Lower priority is tried first; the second entry is a failover.
|
||
// tls_name is needed when a tls:// upstream is written as an IP
|
||
// literal, so certificate verification has a DNS name to match.
|
||
.upstreams = .{
|
||
.{ .url = "https://dns.quad9.net/dns-query", .priority = 10 },
|
||
.{ .url = "tls://9.9.9.9:853", .priority = 20, .tls_name = "dns.quad9.net" },
|
||
},
|
||
|
||
// Exact client assignments win over prefixes.
|
||
.clients = .{
|
||
.{ .ip = "192.168.1.20", .name = "tablet", .group = "kids" },
|
||
},
|
||
.client_prefixes = .{
|
||
.{ .prefix = "192.168.2.0/24", .group = "kids" },
|
||
},
|
||
|
||
.blocklist_sources = .{
|
||
.{ .url = "https://lists.example/ads.txt", .name = "ads" },
|
||
},
|
||
.group_sources = .{
|
||
.{ .group = "kids", .source_url = "https://lists.example/ads.txt" },
|
||
},
|
||
|
||
// Overrides beat blocklists. Wildcards need a label that is exactly "*";
|
||
// a partial label takes a regex, which is unanchored unless you say "^".
|
||
.rules = .{
|
||
.{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow },
|
||
.{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block },
|
||
.{ .group = "kids", .pattern = "^ad[0-9]+-", .kind = .regex, .action = .block },
|
||
},
|
||
|
||
// Local names, answered without any upstream.
|
||
.local_records = .{
|
||
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" },
|
||
.{ .name = "www.lan", .rtype = .cname, .value = "nas.lan" },
|
||
},
|
||
|
||
// Everything under corp.lan goes to the LAN resolver directly.
|
||
.forward_zones = .{
|
||
.{ .zone = "corp.lan", .resolver = "udp://192.168.1.1:53" },
|
||
},
|
||
}
|
||
```
|