Files
nxdns/docs/reference/configuration.md
T

22 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 the defaults), src/config/validate.zig (the rules), src/config/{bootstrap,import,export}.zig (the lifecycle).

For how the file, the database and export/import relate to each other, see the configuration model. For the commands that read and write configuration, see the CLI reference.

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/import.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.
--config FILE /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.

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.read_timeout_ms u32 3000 ms 100120000 read deadline on conditional-forward-zone exchanges (src/local/forward_client.zig)
upstream.total_timeout_ms u32 5000 ms 100120000, and not below 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 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 165535 (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 13600 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 none; 0 disables caching (puts short-circuit) 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 of either family web listener bind (src/web/server.zig)
web.port u16 8080 port 165535 (0 is refused) web listener port
web.password string "" must not be set together with web.password_hash operator input only; hashed at import and discarded. Never a settings row — see Password and hash
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 address

doh_server

The DNS-over-HTTPS listener (server side, for clients on the LAN). See enable DoH and DoT.

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 165535 (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; readability is checked 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 165535 (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 at least 1 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

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

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.

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

Exactly one of web.password and web.password_hash 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. 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. See set up admin authentication.

Validation errors

nxdns check and nxdns import print one path: message line per problem and report every problem, not just the first. 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
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 100120000 ms, or total is below read
BadTtl blocking.ttl, cache.negative_ttl_max, a record ttl, web.session_ttl_hours or blocklist_update.interval_hours outside its range
BadRetention logging.retention_days or logging.query_log_buffer_max below 1
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
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

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" },
    },
}