nantevodocsnantevo-fwdconfiguration

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

[server]

[server]
listen           = "0.0.0.0:53"
fail_closed      = true
# forwarder_doh_url = "https://{endpoint}.nantevo.com/q/{clientID}"
KeyDefaultMeaning
listenrequiredAddress 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_closedtruetrue: 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_urlunsetThe 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_ms10Latency 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_client20Per-source-IP in-flight query cap (flood protection). Excess queries get SERVFAIL.
max_ttl86400TTL ceiling (seconds) applied to responses. Prevents a malicious upstream from locking a poisoned answer in cache with a multi-day TTL.
rebind_protectiontrueBlock 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_ecstrueStrip EDNS Client Subnet from outgoing queries so upstream and authoritative servers never learn client subnets.
safe_searchfalseNetwork-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_enabledtrueIDN homograph (look-alike domain) detection. Only active when homograph_protected_file is also set.
homograph_protected_fileunsetPath to a file of protected brand domains, one per line, # comments allowed. Queries whose Unicode-confusable form matches a protected domain are blocked.
rewrite_fileunsetPath to a global local-DNS rewrite file (syntax below). Reloaded on SIGHUP.
rewrite_ttl300TTL (seconds) for synthetic rewrite answers.

[logging]

[logging]
format   = "json"
level    = "info"
log_file = "/var/log/nantevo-fwd.log"
KeyDefaultMeaning
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_fileunsetInformational 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"
KeyDefaultMeaning
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_hours24Automatic 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"
KeyDefaultMeaning
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_clientstrueRemove 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_signaturestrueLocal 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"
KeyDefaultMeaning
dqs_keyrequiredSpamhaus 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_secs3Per-lookup timeout.
clean_cache_ttl_secs300Cache TTL for clean results; listed results cache for the DNSBL's own response TTL.
enabledtrueMaster switch; per-client override via [[client]].dnsbl_enabled.
NoteDNSBL checking fails open: an unreachable or slow DNSBL never blocks your clients' DNS. Every resolved address in an answer is checked concurrently, so a multi-record answer costs one round-trip, not one per record.

[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):

KeyDefaultMeaning
cache_enabledtrueIndependent response cache for this client.
cache_size1000Max entries before LRU eviction.
min_ttl30TTL floor in seconds.
prefetchtrueBackground refresh of popular records before they expire.
prefetch_ratio0.10Refresh when less than this fraction of the TTL remains (0.0–1.0).
cache_stale_window_secs3600RFC 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):

KeyInheritsMeaning
extra_filter_urlsmergedSupplemental domain blocklists on top of global [filters].
extra_ip_filter_urlsmergedSupplemental IP blocklists.
dnsbl_enabled[dnsbl].enabledSkip 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_filemergedPer-client rewrites; on a domain conflict the client's entry wins over the global file.
[[client.forward]] / inherit_global_forwardsmergedPer-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)
KeyDefaultMeaning
domainsrequiredSuffixes this rule handles: "internal" matches host.internal and svc.host.internal.
upstreamrequiredResolver as IP:port (preferred) or hostname:port. Queries go over UDP with automatic TCP retry on truncation.
timeout_ms2000Per-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
KeyDefaultMeaning
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_days7Days 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_ms500Batch-writer flush period; one transaction (one fsync) per flush. Raise to ~5000 on SD-card storage.
stats_interval_secs5Dashboard stats-poll cadence (the heavy aggregate queries). 30–60 is comfortable on slow storage.
tail_interval_secs1Query-log tail cadence (cheap cursor reads).
summary_window_hours24Default aggregate window for fleet stats (1–168).
Small-SBC tipOn Raspberry Pi / SD-card deployments: 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.