DIY Debian Router - Part 3: DNS infrastructure with Unbound

Oct 7, 2025

Introduction

This is Part 3 of the DIY Debian Router series. See Part 1 for the series introduction and links to all parts.

DNS resolution is a critical router service, translating domain names to IP addresses for all LAN clients. This part covers the deployment of Unbound, a validating recursive DNS resolver designed for security and performance.

The implementation provides:

  • Recursive DNS resolution with root hint traversal (no upstream forwarding)
  • DNS-based ad and tracker blocking via curated blocklists
  • Local domain resolution for internal hostnames
  • Cache optimization for reduced latency

DNS architecture: recursive vs. forwarding

Two common DNS resolver architectures exist:

  1. Forwarding resolver: Forwards queries to upstream DNS servers (e.g., ISP DNS, 8.8.8.8, 1.1.1.1)
  2. Recursive resolver: Queries authoritative DNS servers directly, starting from root servers

This implementation uses Unbound as a recursive resolver. You'll find many posts online about the tradeoffs between recursive vs. forwarding. We won't go into that here, feel free to configure Unbound the way you prefer.

One tradeoff to note however, is that this recursive setup will incur slightly higher latency for the first query to a domain (subsequent queries are cached). I'll be monitoring my experience with this setup over time and will look into trying a forwarding resolver in the future if needed.

Preventing DHCP client interference

By default, DHCP clients (such as dhcpcd or NetworkManager) overwrite /etc/resolv.conf with DNS servers provided by the upstream DHCP server. Since the router will run its own DNS resolver, this behavior must be disabled.

For systems using dhcpcd, append the following to /etc/dhcpcd.conf:

echo "nohook resolv.conf" >> /etc/dhcpcd.conf

This prevents dhcpcd from modifying /etc/resolv.conf. Manually configure /etc/resolv.conf to point to localhost:

cat > /etc/resolv.conf << EOF
nameserver 127.0.0.1
nameserver ::1
EOF

Root hints configuration

Recursive resolvers require a list of root DNS servers (the starting point for DNS query traversal). This list is called root hints.

Download the authoritative root hints file from IANA:

wget -O /etc/unbound/root.hints https://www.internic.net/domain/named.cache

This file contains the IPv4 and IPv6 addresses of the 13 root DNS server clusters (a.root-servers.net through m.root-servers.net). The file should be updated periodically (monthly or quarterly) to reflect any changes, though root server addresses are highly stable.

To automate updates, create a systemd timer and service unit:

/etc/systemd/system/unbound-roothints-update.service

[Unit]
Description=Update Unbound root hints file
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/bin/wget -q -O /etc/unbound/root.hints.new https://www.internic.net/domain/named.cache
ExecStart=/bin/mv /etc/unbound/root.hints.new /etc/unbound/root.hints
ExecStartPost=/bin/systemctl reload unbound
User=root

/etc/systemd/system/unbound-roothints-update.timer

[Unit]
Description=Update Unbound root hints monthly
Requires=unbound-roothints-update.service

[Timer]
OnCalendar=monthly
Persistent=true

[Install]
WantedBy=timers.target

Enable and start the timer:

systemctl daemon-reload
systemctl enable --now unbound-roothints-update.timer

Verify timer status:

systemctl list-timers unbound-roothints-update.timer

DNS blocklist integration

DNS-based blocking (ad/tracker blocking) is implemented by configuring Unbound to return NXDOMAIN or a null address for blocklisted domains.

Blocklist update script

The following script downloads and updates a curated DNS blocklist from OISD, a well-maintained, low-false-positive blocklist.

/etc/unbound/unbound_blocklist_update.sh

Expand to view script
#!/bin/bash
set -euo pipefail

readonly BLOCKLIST_URL="https://big.oisd.nl/unbound"
readonly BLOCKLIST_PATH="/etc/unbound/blocklist.conf"
readonly TEMP_PATH="${BLOCKLIST_PATH}.new"
readonly BACKUP_PATH="${BLOCKLIST_PATH}.old"

log() {
    echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" >&2
}

die() {
    log "ERROR: $*"
    exit 1
}

download_blocklist() {
    log "Downloading blocklist from ${BLOCKLIST_URL}"
    wget --quiet --output-document="${TEMP_PATH}" "${BLOCKLIST_URL}" || \
        die "Failed to download blocklist"

    [[ -s "${TEMP_PATH}" ]] || die "Downloaded blocklist is empty"
}

blocklist_changed() {
    [[ ! -f "${BLOCKLIST_PATH}" ]] || ! diff -q "${BLOCKLIST_PATH}" "${TEMP_PATH}"
}

backup_current() {
    if [ -f "${BLOCKLIST_PATH}" ]; then
	mv "${BLOCKLIST_PATH}" "${BACKUP_PATH}"
    fi
}

restore_backup() {
    if [ -f "${BACKUP_PATH}" ]; then
	cp "${BACKUP_PATH}" "${BLOCKLIST_PATH}"
    fi
}

reload_unbound() {
    systemctl restart unbound >/dev/null
}

umask 027

download_blocklist

if ! blocklist_changed; then
    log "Blocklist unchanged, skipping update"
    rm -f "${TEMP_PATH}"
    exit 1
fi

log "Installing updated blocklist"
backup_current
mv "${TEMP_PATH}" "${BLOCKLIST_PATH}"

if ! reload_unbound; then
    log "WARNING: Unbound restart failed, restoring previous blocklist"
    restore_backup
    reload_unbound || die "Failed to restore Unbound after rollback"
    exit 1
fi

log "Blocklist updated successfully"

Make the script executable:

chmod +x /etc/unbound/unbound_blocklist_update.sh

Run the script to download the initial blocklist:

/etc/unbound/unbound_blocklist_update.sh

To automate updates, create a systemd weekly timer and service unit:

/etc/systemd/system/unbound-blocklist-update.service

[Unit]
Description=Update Unbound DNS blocklist
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/etc/unbound/unbound_blocklist_update.sh
User=root

/etc/systemd/system/unbound-blocklist-update.timer

[Unit]
Description=Update Unbound DNS blocklist weekly
Requires=unbound-blocklist-update.service

[Timer]
OnCalendar=Sun *-*-* 04:00:00
Persistent=true

[Install]
WantedBy=timers.target

Enable and start the timer:

systemctl daemon-reload
systemctl enable --now unbound-blocklist-update.timer

Verify timer status:

systemctl list-timers unbound-blocklist-update.timer

This runs the script every Sunday at 4:00 AM. The script includes error handling to restore the previous blocklist if the update fails or causes Unbound to fail to restart.

To manually trigger an update:

systemctl start unbound-blocklist-update.service

Unbound configuration

The main Unbound configuration file is /etc/unbound/unbound.conf. I've attempted to provide an optimized configuration, with aggressive caching and reasonable security defaults.

/etc/unbound/unbound.conf

Expand to view config
include-toplevel: "/etc/unbound/unbound.conf.d/*.conf"

server:
    # Logging: verbosity level (0-5, higher is more verbose)
    verbosity: 1

    # Network binding: interfaces and protocols to serve
    interface: 192.168.0.1
    interface: 127.0.0.1
    interface: ::1
    port: 53
    do-ip4: yes
    do-ip6: yes
    do-udp: yes
    do-tcp: yes
    prefer-ip4: yes

    # Access control: permit queries only from LAN and localhost
    access-control: 192.168.0.0/24 allow
    access-control: 127.0.0.1 allow
    access-control: fd09:dead:beef::/64 allow
    access-control: 0.0.0.0/0 refuse

    # Root hints: authoritative list of root DNS servers
    root-hints: "/etc/unbound/root.hints"

    # Privacy hardening: prevent resolver fingerprinting
    hide-identity: yes
    hide-version: yes
    hide-trustanchor: yes
    qname-minimisation: yes
    aggressive-nsec: yes

    # Security hardening: cache poisoning and downgrade attack mitigation
    harden-glue: yes
    harden-dnssec-stripped: yes
    harden-below-nxdomain: yes
    harden-referral-path: yes
    harden-algo-downgrade: yes
    use-caps-for-id: yes
    unwanted-reply-threshold: 10000
    val-clean-additional: yes

    # DNSSEC validation: enable with automatic trust anchor updates
    # Key is already included by unbound.conf.d/root-auto-trust-anchor-file.conf
    # auto-trust-anchor-file: "/var/lib/unbound/root.key"
    trust-anchor-signaling: yes
    root-key-sentinel: yes

    # Performance: threading and slab configuration
    num-threads: 4
    msg-cache-slabs: 8
    rrset-cache-slabs: 8
    infra-cache-slabs: 8
    key-cache-slabs: 8
    outgoing-range: 8192
    num-queries-per-thread: 4096
    jostle-timeout: 200

    # Performance: cache tuning and prefetching
    cache-min-ttl: 1800
    cache-max-ttl: 86400
    infra-host-ttl: 900
    infra-cache-numhosts: 10000
    prefetch: yes
    prefetch-key: yes
    serve-expired: yes
    serve-expired-ttl: 3600
    serve-expired-ttl-reset: yes

    # Performance: memory allocation for caches
    rrset-cache-size: 256m
    msg-cache-size: 128m
    neg-cache-size: 4m
    key-cache-size: 128m

    # Performance: network buffer tuning
    so-rcvbuf: 4m
    so-sndbuf: 4m
    so-reuseport: yes

    # Rate limiting: prevent DNS amplification attacks
    ratelimit: 1000
    ip-ratelimit: 1000

    # Local zones: blocklist and LAN transparent zone
    include: "/etc/unbound/blocklist.conf"
    private-address: 192.168.0.0/24
    private-address: fd09:dead:beef::/64

    # Miscellaneous: allow queries to localhost for integration
    do-not-query-localhost: no

    # Locally served zones configured for LAN services
    local-zone: "absurdum.ca" transparent
    local-data: "firewall.absurdum.ca    IN A 192.168.0.1"
    local-data: "ap01.absurdum.ca        IN A 192.168.0.2"
    local-data: "ap02.absurdum.ca        IN A 192.168.0.3"
    local-data: "traefik.absurdum.ca     IN A 192.168.0.10"
    local-data: "git.absurdum.ca         IN A 192.168.0.10"
    local-data-ptr: "192.168.0.10  absurdum.ca"
    local-data-ptr: "192.168.0.1   firewall.absurdum.ca"
    local-data-ptr: "192.168.0.2   ap01.absurdum.ca"
    local-data-ptr: "192.168.0.3   ap02.absurdum.ca"

remote-control:
    control-enable: no

Key configuration aspects:

  • Network binding: LAN interface and loopback only, explicit access control for LAN subnet
  • Privacy: QNAME minimization (RFC 7816), aggressive NSEC caching, hides resolver identity
  • Security: Cache poisoning protections (0x20 encoding, glue/referral validation), DNSSEC downgrade prevention, rate limiting (1000 qps)
  • DNSSEC: Automatic trust anchor updates (RFC 5011), trust anchor signaling (RFC 8145), sentinel detection (RFC 8509)
  • Performance: 4 threads with 8-way cache sharding, aggressive caching (30 min - 24 hour TTL), prefetching, serves stale cache when upstream fails
  • Cache sizing: 256 MB RRset, 128 MB message, 128 MB DNSSEC keys, 4 MB negative (scale for >50 devices)
  • Network tuning: 8192 outgoing ports, 4 MB socket buffers, SO_REUSEPORT load distribution
  • Local zones: Blocklist integration, custom local domain zones, IPv4/IPv6 subnet filtering (DNS rebinding protection)

Finally, start the service:

# Disable unused resolvconf service
systemctl disable --now unbound-resolvconf.service
# Start and enable Unbound
systemctl enable --now unbound
# Verify status
systemctl status unbound

Testing and verification

Query a public domain:

dig @192.168.0.1 example.com

Query a domain with DNSSEC enabled (response should include ad (authenticated data) flag and RRSIG records):

dig @192.168.0.1 cloudflare.com +dnssec

Test DNSSEC validation failure (expect SERVFAIL as response):

dig @192.168.0.1 dnssec-failed.org

Test blocklist, by querying a known tracker domain (check blocklist for a valid example domains):

dig @192.168.0.1 ads.google.cn

Query the same domain twice and compare query times:

dig @192.168.0.1 example.com | grep "Query time"
dig @192.168.0.1 example.com | grep "Query time"

The second query should return in <1 ms (served from cache).

Firewall integration

Unbound should only be accessible from the LAN. Never allow DNS queries from the WAN interface. Part 6 covers nftables rules to enforce this.

Next steps

With DNS resolution working, the router can resolve external domains but clients still lack network configuration (IP addresses, gateway). Part 4 covers DHCPv4 deployment with Kea, providing automatic address assignment and configuration for LAN clients.

RSS
https://yusefkarim.absurdum.ca/posts/feed.xml