Files
nxdns/docs/config-reference.md
T

452 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 defaults), `src/config/validate.zig`
(the rules), `src/config/{bootstrap,import,export}.zig` (the lifecycle).
## How configuration works
The database is the truth; the file is a seed.
- On `nxdns run`, the configuration file (default `/etc/nxdns/config.zon`,
overridable with `--config`) is imported into `config.db` **once**: only
when the file exists and the database has never been configured. On every
later start the file is ignored and the database is used as it is
(`src/config/bootstrap.zig`). A file that exists but is unreadable,
unparseable or invalid fails the start — nxdns never falls back to silent
defaults over a file the operator wrote.
- After the seed, changes are made through the web API (or `nxdns import
--force`), never by editing the file. Editing the file after first boot has
no effect.
- `nxdns export` renders the database back as canonical ZON: fixed two-line
header, every default emitted, deterministic ordering, no timestamps. The
round trip `export` → `import` → `export` is byte-identical. With `--out
FILE` the write is atomic and the file is created mode 0600, because the
export carries `web.password_hash`.
- `nxdns import FILE` replaces the whole database content in one transaction.
Without `--force` it refuses a database that already has content
(`error.DatabaseNotEmpty`); a failed import leaves the database untouched.
- `nxdns check` validates without writing. Source selection order: an
explicit `--config FILE` wins; otherwise `config.db` in the data directory
if it exists; otherwise the default config file path if it exists;
otherwise "nothing to check" (exit 2). `check` also verifies TLS
certificate/key readability and, from the command line, probes each enabled
upstream with a real query.
Absent fields keep their defaults — 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, so a downgrade cannot brick a
config database.
## What is not in the file
Storage paths are process arguments, not configuration:
- `--data-dir DIR` (default `/var/lib/nxdns`) holds `config.db` and
`querylog.db`. The directory is created mode 0700; both databases and their
WAL sidecars are forced to mode 0600.
- `--config FILE` (default `/etc/nxdns/config.zon`) names the seed file.
- `--web-dev DIR` (`run` only) serves the web interface from a directory
instead of the embedded assets.
## File format
The file is ZON: a top-level anonymous struct whose fields are the sections
and collections below. Enum values are written as ZON enum literals
(`.level = .err`, `.response = .nxdomain`). Strings are double-quoted. The
file may be at most 4 MiB (`ConfigTooLarge` beyond that). A syntax error is
reported with its line and column.
One serialization quirk: the log level `error` is the Zig keyword `error`,
so the ZON/model tag is `.err` while the database stores the operator-facing
word `"error"`. `err` is not accepted as database text, and `error` is not a
ZON tag — the file says `.err`, the settings API says `"error"`.
## 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,
e.g. `.dns = .{ .port = 53 }`.
### upstream
Timeouts for talking to upstream resolvers.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `upstream.read_timeout_ms` | u32 | 3000 | ms | 100120000 | read deadline on conditional-forward-zone exchanges (`src/server/handler.zig` via `app.zig`) |
| `upstream.total_timeout_ms` | u32 | 5000 | ms | 100120000, and at least `read_timeout_ms` | per-query budget of the upstream pool (`src/upstream/pool.zig`) — the whole attempt including the connect; also the `nxdns check` probe deadline |
### dns
The plain DNS listener (UDP + TCP).
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `dns.bind_ipv4` | string | `"0.0.0.0"` | IP address | must parse as an IPv4 address | UDP/TCP listener bind (`src/app.zig`) |
| `dns.bind_ipv6` | string | `"::"` | IP address | must parse as an IPv6 address | UDP/TCP listener bind (`src/app.zig`) |
| `dns.port` | u16 | 53 | port | 165535 | 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 | 13600 | window of the same limiter |
### 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 | none; `0` disables caching (put short-circuits — `src/cache/dns_cache.zig` test "a cache of zero entries stores nothing") | 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 |
### 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 | web listener bind (`src/web/server.zig`) |
| `web.port` | u16 | 8080 | port | 165535 | web listener port |
| `web.password` | string | `""` | — | must not be set together with `web.password_hash` | operator input only — see "Authentication" below; never stored, never a settings key |
| `web.password_hash` | string | `""` | — | — | argon2id PHC string verified at login (`src/web/auth.zig`); `""` disables authentication |
| `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 IP |
### doh_server
The DNS-over-HTTPS listener (server side, for clients on the LAN).
| 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 | DoH listener bind |
| `doh_server.port` | u16 | 443 | port | 165535 | DoH listener port |
| `doh_server.cert_path` | string | `"/etc/nxdns/cert.pem"` | path | non-empty when enabled | certificate loaded into the hot-reloading `CertStore`; readability is checked by `nxdns check`, not 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 | DoT listener bind |
| `dot_server.port` | u16 | 853 | port | 165535 | 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 | at least 1 | in-memory query-log ring size and backpressure cap (`src/storage/logger.zig`) |
| `logging.hide_domains` | bool | false | — | — | query log stores a hidden marker instead of the domain |
| `logging.hide_client_ips` | bool | false | — | — | query log stores a hidden marker instead of the client IP |
| `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 absolute path | 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 | rotated generations kept |
### disk
Free-space thresholds for the data directory. When free space falls below
them, the query-log writer, client tracker and blocklist scheduler are
throttled (`src/storage/disk_monitor.zig`).
| 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 only the startup pass runs |
| `blocklist_update.interval_hours` | u16 | 24 | hours | at least 1 | sleep between refresh passes and the per-source staleness test |
## 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 | `https://` (DoH) or `tls://` (DoT) endpoint; unique |
| `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; 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`).
### clients
Known clients with a fixed group assignment.
| Field | Type | Default | Validation |
|---|---|---|---|
| `ip` | string | required | 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 IP → 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 | 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 | `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, compiled into domain sets; a disabled source is neither
downloaded nor loaded.
### 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 silently
skipped.
### rules
Per-group allow/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` | 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.
Consumed by the filter engine's rule sets (`src/filter/rules.zig`).
### local_records
Local DNS answers, served without touching any upstream.
| Field | Type | Default | Validation |
|---|---|---|---|
| `name` | string | required | valid domain name |
| `rtype` | enum `.a` \| `.aaaa` \| `.cname` | required | stored as `A`/`AAAA`/`CNAME` |
| `value` | string | required | IPv4 address for `.a`, IPv6 for `.aaaa`, domain name for `.cname` |
| `ttl` | u32 | 300 | 1604800 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 | 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.
## Authentication: web.password vs web.password_hash
Exactly one of the two may be set; setting both is refused
(`PasswordAndHashBothSet` — ambiguity in a security setting).
- `web.password` is operator input only. At import time it is hashed with
argon2id (OWASP parameters: t=2, m=19 MiB, p=1, PHC encoding) into
`web.password_hash` and discarded. It is never stored — there is no
`web.password` settings row, and `nxdns export` always writes
`.password = ""`.
- `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.
- Both empty disables web authentication entirely.
Because the export carries the hash and re-importing an exported file takes
the "password is empty" branch, the export/import round trip preserves the
hash byte-for-byte.
## 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 at import and never stored;
// 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 "*".
.rules = .{
.{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow },
.{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .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" },
},
}
```