Configuration reference
Every section and key of the nantevo-fwd config.toml, with types, defaults, and semantics. Current as of nantevo-fwd 0.7.4. For deployment and operations, see the guide.
File basics
- Standard location:
/usr/local/etc/nantevo-fwd/config.toml. The binary takes the config path as its (optional) positional argument and defaults to./config.toml. - This file is a secret. Each
[[client]]block embeds that device's DoH URL, and the URL is the credential. Keep itchmod 600, owned by the service user. - Unknown or misspelled keys are rejected at startup with the offending key named in the error — a typo can't silently disable a protection.
SIGHUPreloads the file with zero downtime: client mappings, blocklists, rewrites, forwards, DNSSEC and safe-search settings are recompiled and swapped atomically; in-flight queries finish on the old config. Listen addresses, the dashboard database path, and bootstrap-resolved upstream addresses require a restart.- The only required configuration in the whole file is
[server].listen— everything else has a safe default.
[server]
[server]
listen = "0.0.0.0:53"
fail_closed = true
# forwarder_doh_url = "https://{endpoint}.nantevo.com/q/{clientID}"
| Key | Default | Meaning |
|---|---|---|
listen | required | Address for client DNS, e.g. "0.0.0.0:53". Binds both UDP and TCP. With an unspecified IPv4 address, an IPv6 socket on [::] is bound too when the OS needs one for dual-stack coverage. |
fail_closed | true | true: answer SERVFAIL when a mapped client's upstream fails, or when an unmapped source has no [defaults] URL. false: drop those queries silently. Keep true — a SERVFAIL surfaces the problem instead of silently leaving devices unresolved. |
bootstrap_dns | "1.1.1.1:53" | Resolver used once at startup to bootstrap DoH hostnames. Must be a literal IP:port, never a hostname. |
forwarder_doh_url | unset | The forwarder host's own Nantevo DoH endpoint. When set, bootstrap is two-phase: only this one hostname is resolved in plaintext via bootstrap_dns; every other hostname (per-client endpoints, blocklist hosts, safe-search endpoints) is then resolved through the forwarder's own encrypted channel. When unset, all bootstrap queries go to bootstrap_dns in plaintext and startup logs a warning. |
doh_hedge_ms | 10 | Latency hedging: if the upstream hasn't answered within this many milliseconds, a duplicate request is fired on a second HTTP/2 stream and the first response wins. 0 disables. |
max_concurrent_per_client | 20 | Per-source-IP in-flight query cap (flood protection). Excess queries get SERVFAIL. |
max_ttl | 86400 | TTL ceiling (seconds) applied to responses. Prevents a malicious upstream from locking a poisoned answer in cache with a multi-day TTL. |
rebind_protection | true | Block responses that resolve public names to private, loopback, link-local, CGNAT, or IPv6-equivalent ranges (DNS rebinding defense). DNSBL zones like *.spamhaus.org and *.arpa are exempt; 0.0.0.0/:: null-route answers are not blocked. Per-client override available. |
strip_ecs | true | Strip EDNS Client Subnet from outgoing queries so upstream and authoritative servers never learn client subnets. |
safe_search | false | Network-wide safe-search enforcement (see the guide for engine coverage). Per-client override available. |
youtube_restricted | "off" | YouTube Restricted Mode: "off", "moderate", or "strict". Independent of safe_search; per-client override available. |
homograph_enabled | true | IDN homograph (look-alike domain) detection. Only active when homograph_protected_file is also set. |
homograph_protected_file | unset | Path to a file of protected brand domains, one per line, # comments allowed. Queries whose Unicode-confusable form matches a protected domain are blocked. |
rewrite_file | unset | Path to a global local-DNS rewrite file (syntax below). Reloaded on SIGHUP. |
rewrite_ttl | 300 | TTL (seconds) for synthetic rewrite answers. |
[logging]
[logging]
format = "json"
level = "info"
log_file = "/var/log/nantevo-fwd.log"
| Key | Default | Meaning |
|---|---|---|
format | "pretty" | "json" — structured, one object per line, recommended for production and log aggregators. "pretty" — human-readable color output. The --foreground flag forces pretty output regardless. |
level | "info" | trace · debug · info · warn · error. The RUST_LOG environment variable overrides at runtime. |
log_file | unset | Informational only: the binary always writes to stdout; this tells FreeBSD daemon(8) tooling where stdout is being routed. Under systemd, journald captures stdout directly. |
[filters]
[filters]
urls = [
"https://raw.githubusercontent.com/hagezi/dns-blocklists/main/domains/pro.txt",
]
ip_urls = []
block_response = "nxdomain"
refresh_hours = 24
cache_dir = "/var/db/nantevo-fwd/blocklists"
| Key | Default | Meaning |
|---|---|---|
urls | [] | HTTPS URLs of domain blocklists. Matching is suffix-based and case-insensitive: an entry evil.com also blocks sub.evil.com. |
ip_urls | [] | HTTPS URLs of IP blocklists (bare IPs and CIDR, v4 and v6). Checked against resolved A/AAAA records in answers — a hit means a domain, possibly a trusted one, is resolving to known-bad infrastructure, and is logged as a high-severity event. |
nrd_observe_urls | [] | Newly-registered-domain lists. Matches are flagged in telemetry, never blocked. |
block_response | "nxdomain" | Answer for blocked queries: "nxdomain" or "refused". |
refresh_hours | 24 | Automatic refresh interval. 0 disables scheduled refresh (SIGHUP still forces one). The schedule is driven by cache-file age, so it survives restarts; failed fetches retry after 15 minutes and fall back to the cached copy. |
cache_dir | "/var/db/nantevo-fwd/blocklists" | Local cache for downloaded lists — the forwarder starts and filters normally even if a list host is unreachable. |
Accepted list formats: plain domain-per-line (HaGeZi, Steven Black, oisd, …), hosts-file format (0.0.0.0 domain), #/! comments, and .gz / .tar.gz / .zip archives. Downloads are capped at 128 MiB compressed / 256 MiB decompressed as decompression-bomb protection.
[dnssec]
[dnssec]
mode = "opportunistic"
| Key | Default | Meaning |
|---|---|---|
mode | "opportunistic" | "off" — no DNSSEC handling. "opportunistic" — trust the upstream AD bit; no extra queries, never SERVFAILs. "strict" — full cryptographic validation of the RRSIG chain (DNSKEY/DS) up to the IANA root trust anchor; a signed answer that fails validation gets SERVFAIL, unsigned answers still resolve. Strict mode requires a DNSSEC-capable upstream and adds latency on the first lookup per zone (chain records are cached). |
strip_dnssec_from_non_do_clients | true | Remove RRSIG/NSEC/DNSKEY records from responses to clients that didn't set the DO bit — smaller answers for clients that can't use the records anyway. |
verify_signatures | true | Local RRSIG consistency checks on signed responses before they're served or cached. |
Denial-of-existence (NXDOMAIN/NODATA) is validated for both NSEC and NSEC3 zones, including the NSEC3 closest-encloser proof with opt-out handling. Undecidable edge cases fail open — treated as insecure rather than SERVFAILed.
[dnsbl] — Spamhaus real-time lookups
[dnsbl]
dqs_key = "your-dqs-key"
zone = "xbl.dnsbl.spamhaus.org"
nameserver = "1.1.1.1:53"
| Key | Default | Meaning |
|---|---|---|
dqs_key | required | Spamhaus DQS account key (spamhaus.com/dqs). The whole section is optional; without it, real-time DNSBL checking is simply off. |
zone | "xbl.dnsbl.spamhaus.org" | Zone to query; zen.dnsbl.spamhaus.org adds SBL/PBL coverage. |
nameserver | "1.1.1.1:53" | Literal IP:port for DNSBL queries. Must not point back at this forwarder. |
timeout_secs | 3 | Per-lookup timeout. |
clean_cache_ttl_secs | 300 | Cache TTL for clean results; listed results cache for the DNSBL's own response TTL. |
enabled | true | Master switch; per-client override via [[client]].dnsbl_enabled. |
[defaults] — fallback for unmapped sources
# Remove this section entirely to fail closed for ALL unmapped clients.
[defaults]
doh_url = "https://{endpoint}.nantevo.com/q/{clientID}"
If present, source IPs without a [[client]] mapping resolve through this shared upstream (and still appear in the Unknown Devices view). If absent, unmapped sources are refused per fail_closed. Most zero-trust deployments omit this section — that's what makes enrollment mandatory.
[[client]] — per-device mappings
[[client]]
ip = "10.10.10.10"
name = "web-01"
doh_url = "https://{endpoint}.nantevo.com/q/{clientID}"
[[client]]
ip = "10.10.10.30"
name = "cache-01"
cache_size = 5000
prefetch_ratio = 0.20
extra_filter_urls = [
"https://raw.githubusercontent.com/hagezi/dns-blocklists/main/domains/gambling.txt",
]
doh_url = "https://{endpoint}.nantevo.com/q/{clientID}"
One block per device. ip, name, and doh_url are required; each device's real {endpoint} and {clientID} values come from your client dashboard and are unique per device — the URL is the credential.
Cache tuning (optional, per client):
| Key | Default | Meaning |
|---|---|---|
cache_enabled | true | Independent response cache for this client. |
cache_size | 1000 | Max entries before LRU eviction. |
min_ttl | 30 | TTL floor in seconds. |
prefetch | true | Background refresh of popular records before they expire. |
prefetch_ratio | 0.10 | Refresh when less than this fraction of the TTL remains (0.0–1.0). |
cache_stale_window_secs | 3600 | RFC 8767 serve-stale window: if the upstream is unreachable, expired entries within this window are served with TTL 1 while revalidation happens in the background. 0 disables. |
Policy overrides (optional, per client):
| Key | Inherits | Meaning |
|---|---|---|
extra_filter_urls | merged | Supplemental domain blocklists on top of global [filters]. |
extra_ip_filter_urls | merged | Supplemental IP blocklists. |
dnsbl_enabled | [dnsbl].enabled | Skip or force real-time DNSBL checks for this client. |
rebind_protection | [server] | Override — e.g. false for a client that legitimately resolves internal names to RFC 1918 addresses through public DNS. |
safe_search / youtube_restricted | [server] | Per-device enforcement overrides in either direction. |
rewrite_file | merged | Per-client rewrites; on a domain conflict the client's entry wins over the global file. |
[[client.forward]] / inherit_global_forwards | merged | Per-client forward rules, checked before global [[forward]] rules; set inherit_global_forwards = false to opt out of the global set (default true). |
[[forward]] — internal zones
Route queries for internal zones to your own resolver instead of the DoH upstream — split-horizon DNS for Active Directory, .internal, or lab domains. Matching is suffix-based, first rule wins, and DNSSEC validation is skipped (internal zones are typically unsigned).
[[forward]]
domains = ["corp.example.com", "internal"]
upstream = "10.0.0.10:53"
# timeout_ms = 2000 (default)
| Key | Default | Meaning |
|---|---|---|
domains | required | Suffixes this rule handles: "internal" matches host.internal and svc.host.internal. |
upstream | required | Resolver as IP:port (preferred) or hostname:port. Queries go over UDP with automatic TCP retry on truncation. |
timeout_ms | 2000 | Per-query timeout. If the forward fails, the query falls through to the normal DoH path. |
Rewrite files
Local synthetic DNS records, served before any filtering, caching, or forwarding — useful for lab hosts, NAS boxes, or overriding a name network-wide. Hosts-file-style syntax plus CNAME:
# A / AAAA — address then one or more names
10.0.0.5 nas.home files.home
fd00::5 nas.home
# CNAME alias
CNAME old-name.home nas.home
Matching is subdomain-aware and case-insensitive (sub.nas.home matches a nas.home entry). Answers use [server].rewrite_ttl. Files are re-read on SIGHUP.
[dashboard]
[dashboard]
listen = "127.0.0.1:8080"
db_path = "/var/db/nantevo-fwd/dashboard.db"
retention_days = 7
| Key | Default | Meaning |
|---|---|---|
listen | "127.0.0.1:8080" | Dashboard HTTP address. Loopback by default — see the security note in the guide before changing it. |
db_path | "/var/db/nantevo-fwd/dashboard.db" | SQLite telemetry database (WAL mode). Permissions are forced to 0600 because enrolled-client records include DoH credentials. DNS resolution never depends on this database. |
retention_days | 7 | Days of raw query events to keep; a nightly job aggregates each day into per-client summaries before pruning. |
log_queries | "all" | "all" — every query. "blocked" — only blocked queries; keeps threat/SIEM telemetry while cutting sustained writes by >95% on typical networks. "none" — no persistence. |
write_interval_ms | 500 | Batch-writer flush period; one transaction (one fsync) per flush. Raise to ~5000 on SD-card storage. |
stats_interval_secs | 5 | Dashboard stats-poll cadence (the heavy aggregate queries). 30–60 is comfortable on slow storage. |
tail_interval_secs | 1 | Query-log tail cadence (cheap cursor reads). |
summary_window_hours | 24 | Default aggregate window for fleet stats (1–168). |
log_queries = "blocked", write_interval_ms = 5000, stats_interval_secs = 30, retention_days = 2 — or point db_path at a USB SSD. Telemetry is fully decoupled from resolution, so none of this affects DNS latency.