pebble.
Pebble / Docs / Networking

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.

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:

  1. 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;
  2. parses domain lists, hosts files and simple ||domain^ lines; comments, localhost entries, 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;
  3. stores each feed, sorted and de-duplicated, in /var/db/pebble/blocklist — a failed download keeps the previous copy (last known good);
  4. 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_domains on 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.org and WireGuard endpoints given by name;
  5. writes a candidate file, validates the complete Unbound configuration with it using unbound-checkconf, swaps it in atomically (keeping blocklist.conf.prev), and runs unbound-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.