Files
nxdns/docs/config-reference.md
T

20 KiB
Raw Blame History

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 exportimportexport 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.

.{
    .groups = .{ .{ .name = "default" } },
    .upstreams = .{ .{ .url = "https://dns.quad9.net/dns-query" } },
}

Fuller annotated example

.{
    // 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" },
    },
}