DNS and DNS filtering
Resolver#
Unbound runs from the unbound package with Pebble's configuration at
/usr/local/etc/unbound/pebble/unbound.conf (unbound_config in
rc.conf), chrooted to /usr/local/etc/unbound as the unbound user.
The base system's local_unbound is disabled to free port 53.
- Listens on
127.0.0.1and the LAN address (and tunnel addresses withdns.listen_wireguard); never on the WAN. access-controlrefuses everyone except loopback, the LAN, and networks you add; PF independently allows DNS only from the same networks. Pebble is never an open resolver.- DNSSEC validation with the root trust anchor at
/usr/local/etc/unbound/anchor/root.key, inside the chroot. Onlyanchor/and the key belong to theunbounduser, which must update the key itself; the chroot, holding Pebble's configuration, belongs to root, so a compromised Unbound cannot replace that configuration.pebble bootstrapandpebble applycreate it withunbound-anchorif it is missing (offline,unbound-anchorwrites its built-in root key), becauseunbound-checkconfrejects a configuration whose trust anchor file does not exist. Unbound follows root key rollovers itself (RFC 5011), writing only insideanchor/. (Whether the unbound package's ownrc.d/unboundalso refreshes an anchor at its default location at start-up is to be confirmed on FreeBSD; Pebble's configuration uses onlyanchor/root.key.) - Bounded cache:
dns.cache_mbsplit one third message cache, two thirds RRset cache; one thread. - Hardening: QNAME minimisation, hidden identity and version, DNS rebinding
protection (
private-address) withprivate-domainexceptions for the local and forwarded domains. - Individual queries are not logged unless you ask:
dns.log_queries: truesends them to syslog, anddns.query_log(below) keeps a per-device history for the web interface.
Clock and DNSSEC. Validation needs the correct time and NTP needs DNS.
Pebble enables ntpd with sync-on-start and exempts pool.ntp.org from
validation so that a router whose clock is badly wrong after a power cut
can still reach its time servers. Everything else stays validated.
Forwarded private domains. Conditional forwards to private domains get a
transparent local zone so that Unbound's built-in zones (such as
home.arpa and RFC 1918 reverse zones) do not answer first. If the private
domain is unsigned and not delegated from the public DNS, set
insecure: true on the forward or validation will fail.
Diagnostics#
pebble dns status # unbound-control status plus live checks
pebble dns test nas.home.arpa
pebble dns test ads.example.com
pebble dns cache # aggregate counters only
pebble dns reload # unbound-checkconf, then reload keeping the cache
pebble dns test reports what the configuration says about a name (local
record, local zone, local domain, conditional forward, blocklist) and what
Unbound actually answered. A SERVFAIL that succeeds with checking disabled
is reported as a DNSSEC validation failure; any other SERVFAIL is reported
as an upstream or connectivity failure, because Pebble cannot tell which
from the outside.
Blocklists#
Choice of mechanism: local-zone, not RPZ#
Measured with scripts/bench-dns-filtering.sh (250,000 synthetic domains,
Unbound 1.26.1, one thread):
| Method | Start-up | RSS after start |
|---|---|---|
| no list | 0.14 s | 8.8 MiB |
local-zone (always_nxdomain) |
0.40 s | 146 MiB |
RPZ (name + *.name per domain) |
2.85 s | 369 MiB |
Environment: Apple M2, macOS 27 — not the FreeBSD target; repeat the
script on the router for real figures. The relative result is expected to
hold: an RPZ zone needs two records per domain to cover subdomains and is
held in a full zone structure, while local-zone entries are a single
tree. Pebble therefore uses local-zone directives. Because a local zone
covers all of its subdomains, Pebble also drops listed names whose parent is
already listed, which shrinks typical lists further.
On a 4 GB machine, 250,000 domains cost roughly 150 MB of resident memory
on top of the DNS cache; raise or lower max_domains accordingly.
Processing#
pebble dns blocklist update:
- downloads each feed over HTTPS only (redirects must stay HTTPS, at most
three, and never lead to a loopback, link-local or unspecified address), with a two-minute timeout, conditional requests (
ETag,Last-Modified), and a size limit applied after decompression; - parses domain lists, hosts files and simple
||domain^lines; comments,localhostentries, IP addresses and anything that is not a plain DNS name are discarded and counted. Feed data can never become Unbound syntax: names are limited to[a-z0-9_-]labels; - stores each feed, sorted and de-duplicated, in
/var/db/pebble/blocklist— a failed download keeps the previous copy (last known good); - merges all feeds and the denylist in a single streaming pass (memory
proportional to the number of feeds), removes allowlisted names and
redundant subdomains, and enforces
max_domainson the result. Names the router itself needs are never blocked, whatever a feed says: each feed's own host (so a feed cannot stop its own updates),pool.ntp.org,pkg.freebsd.organd WireGuard endpoints given by name; - writes a candidate file, validates the complete Unbound configuration
with it using
unbound-checkconf, swaps it in atomically (keepingblocklist.conf.prev), and runsunbound-control reload_keep_cache. If Unbound rejects it, the previous list is restored.
pebble apply rebuilds the policy from the cached feeds but never downloads.
Allowlist entries are exempt together with their subdomains, including when
a parent domain is blocked (an always_transparent zone is added).
Scheduling#
With feeds configured, Pebble installs /usr/local/etc/cron.d/pebble with a
schedule derived from update_interval (1h–24h or 7d). Cron runs
pebble dns blocklist update --quiet, which prints only failures.
Commands#
pebble dns blocklist update [--offline] [--dry-run]
pebble dns blocklist status
pebble dns blocklist sources
pebble dns blocklist test www.example.com
Per-device lookup history (opt-in)#
dns:
query_log:
enabled: true # default false
retention: 24h # 1h to 7d
max_mb: 64
monitoring:
enabled: true # required: the recorder collects the lookups
With query_log on, Unbound logs every answer and every local-zone action
(log-replies, log-local-actions) to /usr/local/etc/unbound/log/unbound.log
instead of syslog. The flight recorder reads that file every sample
interval, pairs each answer with the blocking zone that produced it, if
any, and appends the result to hourly files in /var/db/pebble/dnslog
(mode 0700, root only). It empties Unbound's file as it goes and deletes
history older than retention or beyond max_mb. Unbound's own warnings
and errors still reach the event journal.
The web interface's live device page then shows that device's lookups,
newest first: the name, the record type and the result (Answered,
No such name, or Blocked by <zone>, where the zone is the
blocklist, denylist or local_zones entry that matched), with an
All / Blocked only filter. Administrators see the names; viewers see only
how many lookups and blocks there were in the last hour.
Turning query_log off deletes the whole history at the next sample.