Unbound Recursive DNS (Ubuntu)

Disclaimer: This documentation reflects my personal experience and lab setup and is shared to help others learn.

Notes

  • This guide documents the deployment of a public recursive DNS resolver using Unbound on Ubuntu 24.04 LTS, with dnsdist acting as a secure frontend for encrypted DNS protocols.
  • The resolver provides validated DNS recursion only and is intentionally not configured as an authoritative DNS server.
  • The architecture follows a layered design:
    • Unbound performs DNS recursion, caching, and DNSSEC validation.
    • dnsdist provides protocol termination, rate‑limiting, access control, and metrics.
    • Prometheus + Grafana provide observability and long‑term statistics.
  • This configuration prioritizes:
    • DNSSEC correctness and validation
    • Privacy‑preserving recursion
    • Encrypted DNS transports
    • Service isolation and hardening
    • Operational visibility without exposing sensitive internals
  • This build is installed in a homelab / lab environment, not production.
    • While the configuration is production‑grade, ACLs, firewall rules, rate limits, credentials, and monitoring targets must be reviewed before production deployment.
  • The guide is written and tested specifically for Ubuntu 24.04 LTS (Noble).
    • It will likely function on other recent Ubuntu releases, but this has not been explicitly tested.
  • ACME certificates are issued and renewed automatically using Certbot with RFC2136 (TSIG), allowing DNS‑based validation without requiring inbound HTTP access.

Known Pitfalls (Reference)

  • systemd-resolved must be disabled, or it will conflict with Unbound binding to DNS ports.
  • Unbound binds to loopback by default when installed from Ubuntu packages.
  • systemd IPAddress sandboxing prevents binding to non‑local addresses unless explicitly overridden.
  • DNSSEC validation requires /var/lib/unbound to be writable for trust anchor updates.
  • Changes to systemd service overrides require a service restart, not just enable.
  • Unbound statistics for Prometheus require the remote control socket to be enabled.

Features

Core DNS Capabilities

  • Recursive DNS resolution using Unbound
  • Full DNSSEC validation
  • QNAME minimization
  • Hardened resolver behavior
  • Sensible performance and abuse‑resistance limits
  • Separation of recursive resolution (Unbound) and client‑facing services (dnsdist)

Encrypted DNS Protocols (via dnsdist)

  • DNS over TLS (DoT)
  • DNS over QUIC (DoQ)
  • DNS over HTTPS (DoH)
  • DNS over HTTPS/3 (DoH3)

Security & Hardening

  • Strict client access control (ACLs)
  • Kernel‑level network hardening
  • systemd service isolation for Unbound
  • Firewall‑enforced exposure of services
  • Rate limiting and query abuse mitigation (dnsdist)
  • Refusal of ANY queries to reduce amplification risk
  • Clear separation between public‑facing and backend services

Metrics, Monitoring, and Observability

  • DNSTAP‑based telemetry exported from dnsdist
  • dnscollector for real‑time DNS analytics and aggregation
  • Privacy‑preserving client analytics (IPv4 /24 and IPv6 /64 truncation)
  • Authenticated Prometheus metrics endpoints
  • dnsdist metrics for:
    • Frontend query volume and protocol usage
    • Rate‑limit and policy enforcement visibility
  • Unbound metrics exported using the official Prometheus exporter
  • Per‑resolver stream identification based on system hostname
  • Metrics suitable for:
    • Query volume and rate tracking
    • Error rates and DNS response codes
    • DNSSEC validation behavior
    • Cache efficiency and resolver performance
  • Designed for visualization with Grafana dashboards.

Certificate Automation

  • Requires access to an authoritative DNS server for managing ACME DNS challenge records via RFC2136 (TSIG).
  • Automatic TLS certificate issuance and renewal via Certbot
  • DNS‑based validation using RFC2136 (TSIG)
  • No inbound HTTP access required
  • Automatic certificate deployment and service reload on renewal

VM Settings

  • 1vCPU
  • 2GB Memory
  • 40GB of Disk, Thin Provision
  • Ubuntu 24.04 LTS (Minimal installation)
  • The IP address I will be using is 10.0.30.191.
  • The Hostname and Record I’ll be using is:
    • resolver-01.dns.rustysdatabase.com
  • Note: This guide uses the system hostname as a primary identifier and derives many paths and values automatically.
  • You must still customize environment‑specific values such as IP addresses, allowed networks, and credentials. While most commands are copy‑and‑paste friendly, some values are intentionally left configurable to ensure correctness in different environments.

1. Base Server Setup

  • This section prepares a minimal, stable Ubuntu base that is suitable for running a public recursive DNS resolver.
    • The goal here is not to install DNS yet, but to:
      • Fully update the system.
      • Install core utilities used throughout this guide.
      • Establish correct hostname, time, and resolver behavior.

1.1 Update Server and Clean Up

  • apt update && apt -y full-upgrade
  • apt -y autoremove
  • apt clean
    • Updates the system to the latest packages and security patches, then removes unused dependencies and cached files to keep the base install clean before additional services are installed.

1.2 Install Required Packages

  • These packages provide:
    • Core DNS software (Unbound)
    • Security tooling (firewall, fail2ban, unattended upgrades).
    • Logging and time synchronization.
    • Network diagnostics and troubleshooting utilities.
  • Some tools may not be used immediately but are invaluable during testing and troubleshooting.
apt install -y nano ufw fail2ban unattended-upgrades apt-listchanges chrony rsyslog logrotate unbound dnsutils tcpdump net-tools iproute2 bind9-dnsutils bind9-utils ca-certificates
  • Installs the DNS resolver, security tooling, logging, time synchronization, and basic troubleshooting utilities required for a production recursive resolver.
  • Note: During Chrony installation, dpkg may warn about /var/log/chrony not existing. This is harmless and does not affect Chrony operation.

1.3 System Identity, Time, and Resolver Configuration

  • Set Hostname:
    • hostnamectl set-hostname resolver-01.dns.rustysdatabase.com
    • Change resolver-01.dns.rustysdatabase.com to your hostname.
  • edit hosts file:
    • nano /etc/hosts
      • Use your hostname.
127.0.0.1       localhost resolver-01
127.0.1.1       resolver-01.dns.rustysdatabase.com resolver-01
  • This ensures the server can always resolve its own hostname locally, which avoids failures during early boot or if DNS is temporarily unavailable.
  • Enable Chrony:
    • systemctl enable --now chrony
  • Verify Chrony is running and synchronized:
    • chronyc tracking
      • Accurate time is critical for DNSSEC, as signatures are time‑bound and will fail validation if the system clock is incorrect.
  • Set timezone:
    • timedatectl set-timezone America/Chicago
      • Sets the local timezone so system logs and timestamps align with local operations and troubleshooting workflows.
  • Disable systemd-resolved:
    • systemctl disable --now systemd-resolved
      • Disables systemd-resolved to prevent conflicts with Unbound binding to DNS ports.
  • Fix the server resolver:
rm -f /etc/resolv.conf
tee /etc/resolv.conf <<'EOF'
nameserver 208.67.222.222
nameserver 208.67.220.220
options edns0 trust-ad
EOF
  • Replace the nameserver IPs with any reliable public DNS providers.
    • Configures the server itself to use external DNS resolvers instead of localhost.
    • This avoids a dependency loop where the system’s DNS depends on Unbound being healthy.

2. System Hardening

  • This section reduces the server’s attack surface before exposing DNS services to the network.
  • Public recursive resolvers are frequent targets for:
    • Abuse via amplification attacks.
    • Brute‑force login attempts.
    • Information leakage through misconfigured services.
  • The changes below focus on practical, low‑risk hardening that improves security without impacting normal operation.
  • None of these settings are specific to DNS, but they form a solid baseline for any internet‑exposed Linux service.

2.1 SSH Hardening (Password Login Allowed)

  • This guide intentionally allows password-based SSH authentication.
  • For production environments, SSH keys are strongly recommended, but password authentication is retained here to reduce friction during lab builds and learning.
  • Create a separate SSH hardening configuration file:
    • nano /etc/ssh/sshd_config.d/10-hardening.conf
 # Disable root login
PermitRootLogin no

# Authentication (passwords allowed for now)
PasswordAuthentication yes
KbdInteractiveAuthentication yes

# Restrict allowed users
AllowUsers rusty

# Brute-force and session limits
MaxAuthTries 3
MaxSessions 2
LoginGraceTime 30

# Idle session handling
ClientAliveInterval 300
ClientAliveCountMax 2
  • Verify Config:
    • sshd -t
  • Restart SSH:
    • systemctl restart ssh
      • Restricts SSH access to approved users, disables root login, and applies conservative limits to reduce brute‑force attempts and idle sessions while allowing password authentication.

2.2 Firewall Configuration (UFW)

ufw --force reset
ufw default deny incoming
ufw default allow outgoing

ufw allow from 10.0.0.0/8 to any port 22 proto tcp comment 'SSH mgmt (RFC1918)'
ufw allow from 172.16.0.0/12 to any port 22 proto tcp comment 'SSH mgmt (RFC1918)'
ufw allow from 192.168.0.0/16 to any port 22 proto tcp comment 'SSH mgmt (RFC1918)'

ufw allow 53/udp comment 'Public DNS (UDP/53)'
ufw allow 53/tcp comment 'Public DNS (TCP/53)'

ufw enable
ufw status verbose
  • Implements a default‑deny firewall policy while allowing SSH management and public DNS traffic.
  • SSH access is intentionally limited to private address ranges.
  • If remote administration over the public internet is required, additional controls (VPN, bastion hosts, or key‑only authentication) should be used.

2.3 Enable Fail2ban

  • systemctl enable --now fail2ban
  • fail2ban-client status sshd
    • Fail2ban provides automated protection against repeated failed login attempts by dynamically blocking offending IPs.

2.4 Kernel Hardening

  • These kernel settings apply common network hardening best practices suitable for non‑routing servers such as DNS resolvers.
  • Many of these values are already defaults in modern Ubuntu releases. They are explicitly defined here to:
    • Document intent.
    • Ensure predictable behavior.
    • Avoid reliance on distribution defaults.
  • These settings are safe for single‑homed resolver systems and do not affect DNS functionality.
  • nano /etc/sysctl.d/99-dns-hardening.conf
# --------------------------------------------------------------------
# Kernel hardening for DNS resolver (IPv4 + IPv6)
# Designed for high-volume, non-routing servers
# --------------------------------------------------------------------

# --------------------
# Reverse path filtering
# --------------------
# Enable strict reverse-path filtering (safe for single-homed servers)
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1

# --------------------
# Disable routing / forwarding
# --------------------
# This system is not a router
net.ipv4.ip_forward = 0

# --------------------
# ICMP & basic hygiene
# --------------------
# Ignore broadcast ICMP (smurf protection)
net.ipv4.icmp_echo_ignore_broadcasts = 1

# Disable source routing
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0

# Enable TCP SYN cookies
net.ipv4.tcp_syncookies = 1

# --------------------
# Redirect handling
# --------------------
# Do not accept ICMP redirects (servers should not change routes dynamically)
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0

# --------------------
# Logging (keep quiet on noisy resolvers)
# --------------------
# Disable martian logging to reduce noise on high-volume systems
net.ipv4.conf.all.log_martians = 0
net.ipv4.conf.default.log_martians = 0

# --------------------
# IPv6 hardening (IPv6 enabled)
# --------------------
# Ensure IPv6 remains enabled (even if not active yet)
net.ipv6.conf.all.disable_ipv6 = 0
net.ipv6.conf.default.disable_ipv6 = 0

# Disable IPv6 source routing
net.ipv6.conf.all.accept_source_route = 0
net.ipv6.conf.default.accept_source_route = 0

# Disable IPv6 redirects
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
  • Apply the settings:
    • sysctl --system

3. DNS Resolver Configuration (Unbound)

  • Unbound will serve as the recursive DNS engine in this architecture.
  • It is intentionally:
    • Not exposed directly to the network.
    • Bound only to loopback.
    • Used exclusively by dnsdist as a backend.
  • Unbound is responsible for recursion, caching, and DNSSEC validation.
  • dnsdist handles all client-facing protocols, encryption, rate‑limiting, and access control.
  • This separation keeps the resolver simpler, easier to audit, and more resilient.

3.1 Unbound Configuration

  • Ubuntu provides DNSSEC trust anchors by default. Do not manually configure trust anchor files.
    • Create the Unbound recursive resolver configuration file:
      • nano /etc/unbound/unbound.conf.d/recursive.conf
        • The configuration below enables Unbound as a hardened, validating recursive resolver with conservative performance limits.
        • You do not need to understand every directive immediately.
        • Sensible defaults are used, and each category is grouped logically for clarity.
server:
  interface: 127.0.0.1
  port: 5353

  do-ip4: yes
  do-ip6: yes
  do-udp: yes
  do-tcp: yes

  # ACCESS CONTROL
  access-control: 127.0.0.0/8 allow
  access-control: ::1 allow

  # Replace RFC1918 with CUSTOMER PUBLIC NETBLOCKS before production.
  access-control: 10.0.0.0/8 allow
  access-control: 172.16.0.0/12 allow
  access-control: 192.168.0.0/16 allow

  access-control: 0.0.0.0/0 deny
  access-control: ::/0 deny

  # PRIVACY / HARDENING
  hide-identity: yes
  hide-version: yes
  qname-minimisation: yes
  harden-dnssec-stripped: yes
  harden-referral-path: yes
  do-not-query-localhost: yes

  
  # Prevents oversized UDP responses and improves reliability across modern networks
  edns-buffer-size: 1232
  max-udp-size: 1232

  num-threads: 2
  so-reuseport: yes
  outgoing-range: 4096
  num-queries-per-thread: 2048
  jostle-timeout: 200
  wait-limit: 1000
  wait-limit-cookie: 10000

  # LOGGING
  use-syslog: yes
  verbosity: 1
  • These values are intentionally conservative and suitable for small to mid-sized resolvers.
  • High‑volume environments may require tuning based on traffic patterns and available CPU cores.
  • Configures Unbound as a hardened, validating recursive resolver optimized for use behind dnsdist.

3.2 – Systemd Hardening Override

  • These systemd settings isolate the Unbound service from the rest of the system.
  • They restrict filesystem access, kernel interaction, and privilege escalation while explicitly allowing the paths and interfaces Unbound requires to function correctly.
  • mkdir -p /etc/systemd/system/unbound.service.d
  • nano /etc/systemd/system/unbound.service.d/override.conf
[Service]
# Prevent privilege escalation
NoNewPrivileges=yes

# Isolate runtime environments
PrivateTmp=yes
PrivateDevices=yes

# Filesystem protections
ProtectSystem=full
ProtectHome=yes

# Kernel and control plane hardening
ProtectKernelModules=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictNamespaces=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes

# Required for network interface enumeration
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK

# Required for DNSSEC trust anchor updates and runtime sockets
ReadWritePaths=/var/lib/unbound /run

# Allow network access (dnsdist-fronted resolver)
IPAddressDeny=
IPAddressAllow=any
  • systemctl daemon-reload
  • systemctl enable unbound
  • systemctl restart unbound
    • Applies service‑level isolation while explicitly allowing required DNS functionality.

3.3 Verify Binding

  • ss -lntup | grep ':5353'
    • If Unbound is not listening on 127.0.0.1:5353, stop and resolve this before continuing.
  • Output should look similar to:
udp   UNCONN 0      0          127.0.0.1:5353      0.0.0.0:*    users:(("unbound",pid=1837,fd=5))
udp   UNCONN 0      0          127.0.0.1:5353      0.0.0.0:*    users:(("unbound",pid=1837,fd=3))
tcp   LISTEN 0      256        127.0.0.1:5353      0.0.0.0:*    users:(("unbound",pid=1837,fd=6))
tcp   LISTEN 0      256        127.0.0.1:5353      0.0.0.0:*    users:(("unbound",pid=1837,fd=4))
  • Unbound should only be listening on loopback.
  • If it is bound to external interfaces, access controls are not being enforced correctly.

4. Testing and Verification

  • Before introducing dnsdist, encryption, or monitoring, verify that Unbound is functioning correctly on its own.
  • These tests confirm:
    • Basic recursive resolution.
    • Proper DNSSEC validation.
    • Consistent behavior when queried via different local interfaces.
  • If any of the tests below do not behave as expected, resolve them before continuing.
  • dig @127.0.0.1 -p 5353 rustysdatabase.com
    • Expected: NOERROR
    • This confirms Unbound can perform normal recursive resolution successfully.
  • dig @127.0.0.1 -p 5353 dnssec-failed.org A +dnssec
    • Expected: SERVFAIL
    • dnssec-failed.org is intentionally mis‑signed.
    • A SERVFAIL response confirms that DNSSEC validation is enabled and working correctly.
    • Returning an address here would indicate a serious DNSSEC misconfiguration.
  • dig @localhost -p 5353 rustysdatabase.com
    • This confirms the system resolver configuration is functioning correctly and that name resolution behaves consistently across local interfaces.
  • dig @127.0.0.1 -p 5353 cloudflare.com +dnssec +multi
    • Expected: NOERROR and ad flag present.
    • The ad (Authenticated Data) flag indicates DNSSEC validation succeeded for this response.

5. Encrypted DNS Frontends (DoT, DoQ, DoH, DoH3) with dnsdist

  • In this section, dnsdist is introduced as the public‑facing DNS frontend.
  • dnsdist is responsible for:
    • Terminating encrypted DNS protocols.
    • Enforcing access control and rate limits.
    • Forwarding validated queries to Unbound.
  • Unbound remains bound to loopback and is never exposed directly.
  • All client traffic flows through dnsdist first.
  • While the dnsdist configuration may appear long, most of it consists of:
    • Listener definitions.
    • Security defaults.
    • Explicit protocol enablement.
  • The overall design remains simple: dnsdist receives queries, Unbound answers them.
  • Supported protocols:
    • DNS over TLS (DoT)
    • DNS over QUIC (DoQ)
    • DNS over HTTPS (DoH)
    • DNS over HTTP/3 (DoH3)
  • Certificates are obtained and renewed automatically using Certbot with RFC2136 (TSIG), allowing certificate issuance even when the server is behind a firewall.
    • TLS certificates are required for all encrypted DNS protocols.
    • This guide uses DNS‑based ACME validation (RFC2136 with TSIG), which allows certificates to be issued and renewed without exposing HTTP services or temporarily opening firewall holes.

5.1 Firewall Prerequisites

  • Allow encrypted DNS protocols through the firewall.
  • Firewall rules:
ufw allow 853/tcp comment 'DNS-over-TLS (DoT)'
ufw allow 853/udp comment 'DNS-over-QUIC (DoQ)'
ufw allow 443/tcp comment 'DNS-over-HTTPS (DoH)'
ufw allow 443/udp comment 'DNS-over-HTTP/3 (DoH3)'

ufw reload
ufw status verbose
  • Note: Both TCP and UDP must be allowed on ports 443 and 853 to support all encrypted DNS protocols.
    • TCP is used for DoT and DoH.
    • UDP is used for DoQ and DoH3.

5.2 Certificate Management (Certbot + RFC2136 TSIG)

  • Note: DNS-based ACME validation requires control of the authoritative DNS zone for the certificate name.
  • This guide assumes you already operate or have access to an authoritative DNS server capable of RFC2136 (TSIG) updates.
  • Install Certbot and the RFC2136 DNS Plugin:
    • apt -y install certbot python3-certbot-dns-rfc2136
  • Configure TSIG Credentials File:
    • nano /etc/letsencrypt/rfc2136.ini
      • Change the IP address to your authoritative dns server.
dns_rfc2136_server = 10.0.30.181
dns_rfc2136_port = 53

dns_rfc2136_name = tsig-acme.dns.rustysdatabase.com
dns_rfc2136_secret = Secret
dns_rfc2136_algorithm = HMAC-SHA384
  • Secure the credentials file:
    • chmod 600 /etc/letsencrypt/rfc2136.ini
    • chown root:root /etc/letsencrypt/rfc2136.ini
  • Request a certificate
    • Change the email to your email.
certbot certonly \
  --non-interactive \
  --agree-tos \
  --email example@rustysdatabase.com \
  --dns-rfc2136 \
  --dns-rfc2136-credentials /etc/letsencrypt/rfc2136.ini \
  --dns-rfc2136-propagation-seconds 10 \
  -d "$(hostname -f)"
  • Certificates will be stored at:
/etc/letsencrypt/live/<your-hostname>/
  ├─ fullchain.pem
  └─ privkey.pem
  • These certificates will be referenced by dnsdist for all encrypted listeners.

5.3 dnsdist Setup

  • Add PowerDNS Repository:
    • dnsdist is installed from the official PowerDNS repository to ensure access to current features such as DoQ and DoH3.
curl -fsSL https://repo.powerdns.com/FD380FBB-pub.asc \
  | gpg --dearmor \
  | tee /usr/share/keyrings/pdns.gpg >/dev/null

echo "deb [signed-by=/usr/share/keyrings/pdns.gpg] \
https://repo.powerdns.com/ubuntu noble-dnsdist-20 main" \
> /etc/apt/sources.list.d/pdns-dnsdist.list
  • Pin the repository to avoid unintended upgrades:
cat >/etc/apt/preferences.d/pdns-dnsdist <<'EOF'
Package: dnsdist*
Pin: origin repo.powerdns.com
Pin-Priority: 600
EOF
  • Install dnsdist:
    • apt -y update && apt -y install dnsdist
  • Create a user to run dnsdist:
    • adduser --system --group --no-create-home dnsdist
  • Modify the systemd service to use the dnsdist user:
    • Running dnsdist as a dedicated unprivileged user significantly reduces the impact of a potential compromise.
    • systemctl edit dnsdist
[Service]
# Run dnsdist as a dedicated unprivileged user
User=dnsdist
Group=dnsdist
DynamicUser=no

# Runtime directory for sockets and state
RuntimeDirectory=dnsdist
RuntimeDirectoryMode=0755

# Basic privilege hardening
NoNewPrivileges=yes
PrivateTmp=yes

# Filesystem protections
ProtectSystem=full
ProtectHome=yes

# Kernel / control-plane hardening
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes

# Required network families
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
  • Restart systemd:
    • systemctl daemon-reload

5.4 dnsdist Configuration

  • The configuration below enables all supported DNS protocols and forwards queries to Unbound running on 127.0.0.1:5353.
  • The file is structured into clearly commented sections.
  • You are encouraged to read through it slowly, but it is safe to apply as‑is for lab environments.
  • Create a setKey
    • openssl rand -base64 32
  • Create the dnsdist configuration file:
    • nano /etc/dnsdist/dnsdist.conf
      • Near the top of the file there is setKey("REPLACE_WITH_RANDOM_KEY") Replace the text REPLACE_WITH_RANDOM_KEY with the key generated above.
      • This configuration enables plain DNS, DoT, DoQ, DoH, and DoH3, forwarding all queries to Unbound on 127.0.0.1:5353.
-- ============================================================
-- dnsdist front-end for unbound (Ubuntu 24.04 / dnsdist 2.x)
-- Protocols: DNS, DoT, DoQ, DoH, DoH3
-- Backend:   unbound on 127.0.0.1:5353
-- Certs:     /etc/dnsdist/cert/fullchain.pem + privkey.pem
-- ============================================================

-- -------------------------
-- Basics / safety
-- -------------------------
setSecurityPollSuffix("")      -- don't leak lookups

-- Console encryption key (generate with: openssl rand -base64 32)
setKey("REPLACE_WITH_RANDOM_KEY")

-- Allow recursion access only from your networks (replace RFC1918 w/ customer public netblocks in prod)
setACL({
  "127.0.0.1/32",
  "10.0.0.0/8",
  "172.16.0.0/12",
  "192.168.0.0/16",
  "::1/128",
  "fc00::/7"
})

-- -------------------------
-- Downstream (unbound)
-- -------------------------
newServer({
  address = "127.0.0.1:5353",
  name    = "unbound",
  pool    = "recursor",

  -- Health check tuning (reduced noise)
  checkType = "A",
  checkInterval = 30,
  maxCheckFailures = 3,
  rise = 1
})

-- -------------------------
-- Edge controls (POLICY FIRST)
-- -------------------------
-- IMPORTANT: dnsdist evaluates rules top-down; place security/policy rules
-- BEFORE any catch-all routing (like AllRule() -> PoolAction()). [1](https://www.dnsdist.org/rules-actions.html)

-- Drop/Refuse ANY (amplification reduction)
-- Alternative is “only drop ANY over UDP” (seen in examples)
addAction(QTypeRule(DNSQType.ANY), RCodeAction(DNSRCode.REFUSED))

-- QPS limiting
-- For ISP, per-IP limits can hurt NAT/CGNAT. Use subnet-based limits instead.
-- Example: measure per /24 IPv4 and /48 IPv6
addAction(MaxQPSIPRule(1000, 24, 48), TCAction())     -- signal truncation; clients fall back to TCP
addAction(NotRule(MaxQPSRule(50000)), DropAction())   -- global cap (optional pattern)

-- Optional: dynamic blocking (more flexible than fixed limits)
-- local dbr = dynBlockRulesGroup()
-- dbr:setQueryRate(2000, 10, "Exceeded query rate", 60)
-- function maintenance() dbr:apply() end

-- -------------------------
-- Default routing (CATCH-ALL LAST)
-- -------------------------
-- Apply server selection policy to the pool (preferred with pools)
setPoolServerPolicy(firstAvailable, "recursor")
addAction(AllRule(), PoolAction("recursor"))

-- -------------------------
-- TLS / QUIC certificates
-- -------------------------
local FULLCHAIN = "/etc/dnsdist/cert/fullchain.pem"
local PRIVKEY   = "/etc/dnsdist/cert/privkey.pem"
-- NOTE: ensure cert/key are readable by the dnsdist user; docs recommend copying via ACME deploy-hook

-- -------------------------
-- Listeners
-- -------------------------

-- Plain DNS :53 (explicit TCP enabled recommended)
addLocal("0.0.0.0:53", { reusePort=true, doTCP=true })
addLocal("[::]:53",    { reusePort=true, doTCP=true })

-- DoT :853
addTLSLocal("0.0.0.0:853", FULLCHAIN, PRIVKEY, { reusePort=true })
addTLSLocal("[::]:853",    FULLCHAIN, PRIVKEY, { reusePort=true })

-- DoQ :853 (UDP/QUIC)
addDOQLocal("0.0.0.0:853", FULLCHAIN, PRIVKEY, { reusePort=true })
addDOQLocal("[::]:853",    FULLCHAIN, PRIVKEY, { reusePort=true })

-- DoH :443 (HTTP/2)
addDOHLocal("0.0.0.0:443", FULLCHAIN, PRIVKEY, "/dns-query",
  { reusePort=true, customResponseHeaders = { ["alt-svc"] = "h3=\":443\"" } }
)
addDOHLocal("[::]:443", FULLCHAIN, PRIVKEY, "/dns-query",
  { reusePort=true, customResponseHeaders = { ["alt-svc"] = "h3=\":443\"" } }
)

-- DoH3 :443 (HTTP/3)
addDOH3Local("0.0.0.0:443", FULLCHAIN, PRIVKEY, { reusePort=true })
addDOH3Local("[::]:443",    FULLCHAIN, PRIVKEY, { reusePort=true })

-- -------------------------
-- Control socket
-- -------------------------
setConsoleACL({ "127.0.0.1/32", "::1/128" })
controlSocket("127.0.0.1:5199")
  • Set Permissions
    • chown root:dnsdist /etc/dnsdist/dnsdist.conf

5.5 Certificate Deployment (Initial Startup and Renewal)

  • Create a directory for dnsdist certificates:
    • mkdir -p /etc/dnsdist/cert
    • chown root:dnsdist /etc/dnsdist/cert
    • chmod 750 /etc/dnsdist/cert
  • Copy certificates for initial startup:
    • During initial deployment, certificates must be copied into place before starting dnsdist. Ongoing renewals are handled automatically by the Certbot deploy hook.
      • install -m 0644 /etc/letsencrypt/live/"$(hostname -f)"/fullchain.pem /etc/dnsdist/cert/fullchain.pem
      • install -m 0640 -g dnsdist /etc/letsencrypt/live/"$(hostname -f)"/privkey.pem /etc/dnsdist/cert/privkey.pem
  • Restart dnsdist:
    • systemctl daemon-reload
    • systemctl enable dnsdist
    • systemctl start dnsdist
  • Create a certbot deploy hook to copy renewed certificates and set permissions:
    • dnsdist does not read certificates directly from Let’s Encrypt directories.
    • Certificates are copied into a controlled location with explicit permissions on each renewal.
    • nano /etc/letsencrypt/renewal-hooks/deploy/dnsdist-copy.sh
#!/bin/bash
set -e

LOGGER_TAG="certbot-dnsdist"

CERT="$(hostname -f)"
SRC="/etc/letsencrypt/live/${CERT}"
DST="/etc/dnsdist/cert"

logger -t "${LOGGER_TAG}" "Deploying renewed certificate for ${CERT}"

install -m 0644 "${SRC}/fullchain.pem" "${DST}/fullchain.pem"
install -m 0640 -g dnsdist "${SRC}/privkey.pem" "${DST}/privkey.pem"

logger -t "${LOGGER_TAG}" "Certificate files installed, restarting dnsdist"

systemctl restart dnsdist

logger -t "${LOGGER_TAG}" "dnsdist restart completed successfully"
  • Make it executable:
    • chmod 750 /etc/letsencrypt/renewal-hooks/deploy/dnsdist-copy.sh
  • Test the hook manually:
    • cd /etc/letsencrypt/renewal-hooks/deploy/
    • ./dnsdist-copy.sh

5.6 Testing and Verification

  • Verify the dnsdist service is listening on all expected ports:
    • ss -lntup | grep dnsdist
      • Should see listeners for:
        • :53 TCP + UDP
        • :853 TCP (DoT)
        • :853 UDP (DoQ)
        • :443 TCP (DoH)
        • :443 UDP (DoH3)
        • 127.0.0.1:5199 (control socket)
    • Test Plain DNS
      • dig @127.0.0.1 cloudflare.com
      • Expected NOERROR
    • DNSSEC through dnsdist
      • dig @127.0.0.1 dnssec-failed.org A +dnssec
      • Expected SERVFAIL
    • Verify ANY queries are refused (amplification reduction)
      • dig @127.0.0.1 cloudflare.com ANY
      • Expected REFUSED
    • DNS over TLS
      • kdig +tls @127.0.0.1 cloudflare.com
      • Expected NOERROR, no TLS errors
    • DNS Over HTTP
      • curl -I https://$(hostname -f)/dns-query
      • Expected 400 Response Code

6. Metrics, Monitoring, and Statistics

  • This section adds visibility into resolver behavior using Prometheus and Grafana.
  • Monitoring allows you to answer practical operational questions such as:
    • How much DNS traffic is this resolver handling?
    • Which domains and clients are the most active?
    • Are errors or DNSSEC validation failures increasing?
    • Is performance degrading over time?
  • While monitoring is not required for a functional resolver, it is strongly recommended for any long‑running or production deployment.
  • This setup collects metrics from three layers:
    • dnsdist – frontend traffic, protocol usage, rate limiting.
    • Unbound – recursion, cache behavior, DNSSEC validation.
    • dnscollector – per‑domain and per‑client analytics using dnstap.
  • Prometheus scrapes metrics from each component, and Grafana visualizes them.

6.1 Firewall Rules for Metrics Collection

  • Allow Prometheus to scrape metrics from the resolver:
    • Metrics endpoints are deliberately restricted to the Prometheus server to avoid exposing operational data publicly.
    • Change 10.0.20.10 to your Grafana/Prometheus server.
ufw allow from 10.0.20.10 to any port 8081 proto tcp comment 'Allow Prometheus to scrape dnstap metrics'
ufw allow from 10.0.20.10 to any port 8083 proto tcp comment 'Allow Prometheus to scrape dnsdist metrics'
ufw allow from 10.0.20.10 to any port 9167 proto tcp comment 'Allow Prometheus to scrape unbound metrics'

6.2 dnsdist Metrics Configuration

  • dnsdist includes a built‑in webserver that exposes internal metrics in Prometheus format.
  • These metrics focus on client traffic, protocol usage, and frontend behavior.
  • Edit the dnsdist configuration:
    • Add this to the bottom of the dnsdist.conf file
    • nano /etc/dnsdist/dnsdist.conf
-- -------------------------
-- Metrics / stats (Prometheus via /metrics)
-- -------------------------
-- The dnsdist built-in webserver exposes:
--   /metrics  (Prometheus)
--   /jsonstat (stats)
--   /api/...  (API, requires apiKey)
webserver("0.0.0.0:8083")

-- Recommended: hashed creds instead of plaintext
-- You can generate hashes by running: dnsdist -e 'print(hashPassword("changeme"))'
local WEB_PASS_HASH = hashPassword("changeme")
local API_KEY_HASH  = hashPassword("changeme")

setWebserverConfig({
  password = WEB_PASS_HASH,
  apiKey   = API_KEY_HASH,

  -- Lock it down to only what needs it
  acl = "127.0.0.1/32,::1/128,10.0.0.0/8",

  -- Optional: keep auth required for stats endpoints (default is true)
  statsRequireAuthentication = false,
  dashboardRequiresAuthentication = true,
  apiRequiresAuthentication = true
})
  • Restart dnsdist:
    • systemctl restart dnsdist
      • This exposes authenticated Prometheus metrics at /metrics.
      • Authentication is enabled to prevent unauthenticated access to operational data.

6.3 Unbound Statistics Configuration

  • These statistics expose resolver behavior such as cache hits, recursion counts, and DNSSEC validation outcomes.
  • Create a dedicated statistics configuration file:
    • nano /etc/unbound/unbound.conf.d/statistics.conf
server:
    # Disable periodic log dumping
    statistics-interval: 0

    # Enable detailed counters
    extended-statistics: yes

    # Required for Prometheus-style counters
    statistics-cumulative: yes
  • Keeping this in a separate file makes statistics configuration easier to manage and audit.

6.4 Enable Unbound Remote Control

  • The remote control socket is required by the Prometheus exporter to query Unbound’s internal statistics.
  • Create the remote control configuration file:
    • nano /etc/unbound/unbound.conf.d/remote.conf
remote-control:
  control-enable: yes
  control-interface: /run/unbound.ctl
  • Restart unbound:
    • systemctl restart unbound

6.5 Install Unbound Prometheus Exporter

  • Install Go (required to build the exporter):
    • apt update && apt -y install golang-go
  • Build the unbound exporter:
    • go install github.com/letsencrypt/unbound_exporter@latest
  • Install the unbound exporter:
    • mv ~/go/bin/unbound_exporter /usr/local/bin/
    • chmod 755 /usr/local/bin/unbound_exporter

6.6 Configure Unbound Exporter Service

  • Create a systemd service:
    • nano /etc/systemd/system/unbound_exporter.service
[Unit]
Description=Prometheus Unbound Exporter
After=network.target unbound.service
Requires=unbound.service

[Service]
ExecStart=/usr/local/bin/unbound_exporter \
  -unbound.host=unix:///run/unbound.ctl \
  -web.listen-address=0.0.0.0:9167

User=unbound
Group=unbound

Restart=always
RestartSec=5

# Basic service hardening
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes

# Required address families
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6

[Install]
WantedBy=multi-user.target
  • Enable and start the exporter:
    • systemctl daemon-reload
    • systemctl enable --now unbound_exporter
  • After startup, Unbound metrics will be available to Prometheus on port 9167.
  • Restart Unbound:
    • systemctl restart unbound

6.7 Install dnscollector

  • dnscollector processes dnstap output and converts it into privacy‑aware Prometheus metrics.
  • Download dnscollector:
cd /usr/local/src
wget https://github.com/dmachard/DNS-collector/releases/download/v2.2.0/DNS-collector_2.2.0_linux_amd64.tar.gz
tar xzf DNS-collector_2.2.0_linux_amd64.tar.gz
  • Install:
install -m 0755 dnscollector /usr/local/bin/dnscollector

6.8 Configure dnscollector

  • dnscollector consumes query and response data exported by dnsdist via dnstap.
  • It performs:
    • Domain normalization (including eTLD+1).
    • Privacy‑preserving IP anonymization.
    • Aggregation for top‑N analytics.
  • Create the directories:
    • mkdir -p /etc/dnscollector
      • /etc/dnscollector – configuration files
    • mkdir -p /var/lib/dnscollector
      • /var/lib/dnscollector – runtime/state data
  • Create the configuration file:
    • nano /etc/dnscollector/config.yml
################################################
# DNS Collector configuration
# Input:  dnsdist dnstap unix socket
# Output: Prometheus exporter on :8081 (basic auth)
################################################

global:
  trace:
    verbose: true
    log-malformed: true

pipelines:

  ################################################
  # Pipeline 0: DNSTAP input from dnsdist
  ################################################
  - name: dnstap-input

    dnstap:
      listen-ip: 127.0.0.1
      listen-port: 6000

    transforms:

      # Normalize DNS names
      normalize:
        qname-lowercase: true
        qname-replace-nonprintable: true
        add-tld: true
        add-tld-plus-one: true

      # Privacy transform:
      # Keep ONLY the key your binary accepts right now.
      # (Your dnscollector currently rejects minimaze-qname.)
      user-privacy:
        anonymize-ip: true
        anonymize-v4bits: "/24"
        anonymize-v6bits: "::/64"

    routing-policy:
      forward: [prometheus]
      dropped: []

  ################################################
  # Pipeline 1: Prometheus exporter
  ################################################
  - name: prometheus

    prometheus:
      listen-ip: 0.0.0.0
      listen-port: 8081

      basic-auth-enable: true
      basic-auth-login: admin
      basic-auth-pwd: changeme

      # You want top 100 in Grafana
      top-n: 100

      # Enable the top-N metric families
      requesters-metrics-enabled: true
      domains-metrics-enabled: true

      # RCODE families
      noerror-metrics-enabled: true
      servfail-metrics-enabled: true
      nonexistent-metrics-enabled: true

6.9 Configure The dnscollector service

  • nano /etc/systemd/system/dnscollector.service
[Unit]
Description=DNS Collector
After=network-online.target dnsdist.service
Wants=network-online.target dnsdist.service

[Service]
ExecStart=/usr/local/bin/dnscollector -config /etc/dnscollector/config.yml
Restart=always
RestartSec=5
User=root

[Install]
WantedBy=multi-user.target
  • dnscollector must start after dnsdist to ensure the dnstap stream is available.
  • Restart the daemon and enable/start dnscollector:
    • systemctl daemon-reload
    • systemctl enable dnscollector
    • systemctl start dnscollector
    • systemctl status dnscollector
  • Verify the service is listening:
    • ss -tulpn | grep 8081
  • Configure dnsdist to use dnstap to collect dnscollector statistics:
    • nano /etc/dnsdist/dnsdist.conf
      • Add this to the bottom of the file.
-- ============================================================
-- DNSTAP logging -> dnscollector (TCP on loopback)
-- ============================================================

local DNSTAP_ENDPOINT = "127.0.0.1:6000"

-- Use the system hostname as identity (avoids hardcoding)
local f = io.popen("/bin/hostname")
local DNSTAP_IDENTITY = (f:read("*a") or "dnsdist")
f:close()
DNSTAP_IDENTITY = string.gsub(DNSTAP_IDENTITY, "\n$", "")

-- FrameStream TCP logger (loopback)
local dnstapLogger = newFrameStreamTcpLogger(DNSTAP_ENDPOINT)

-- Log client queries + responses
addAction(AllRule(), DnstapLogAction(DNSTAP_IDENTITY, dnstapLogger))
addResponseAction(AllRule(), DnstapLogResponseAction(DNSTAP_IDENTITY, dnstapLogger))
  • Restart dnsdist
    • systemctl restart dnsdist

6.10 Prometheus Configuration (Grafana Server)

  • nano /etc/prometheus/prometheus.yml
  - job_name: "dnsdist"
    scrape_interval: 15s
    scrape_timeout: 10s
    metrics_path: /metrics
    static_configs:
      - targets:
          - "10.0.30.191:8083"
        labels:
          resolver: "resolver-01"
          service: "dnsdist"
          site: "Site 1"
          env: "prod"

      - targets:
          - "10.0.30.192:8083"
        labels:
          resolver: "resolver-02"
          service: "dnsdist"
          site: "Site 2"
          env: "prod"

  - job_name: "unbound"
    scrape_interval: 15s
    scrape_timeout: 10s
    metrics_path: /metrics
    static_configs:
      - targets:
          - "10.0.30.191:9167"
        labels:
          resolver: "resolver-01"
          service: "unbound"
          site: "Site 1"
          env: "prod"

      - targets:
          - "10.0.30.192:9167"
        labels:
          resolver: "resolver-02"
          service: "unbound"
          site: "Site 2"
          env: "prod"

  - job_name: dnscollector
    basic_auth:
      username: "admin"
      password: "changeme"
    static_configs:
      - targets:
          - "10.0.30.191:8081"
        labels:
          resolver: "resolver-01"
          service: "dnsdist"
          site: "Site 1"
          env: "prod"

      - targets:
          - "10.0.30.192:8081"
        labels:
          resolver: "resolver-02"
          service: "dnsdist"
          site: "Site 2"
          env: "prod"
  • Add additional targets as more resolvers are deployed.

To Do/Notes

  • dnstap
    • Domain Categories
  • Lazy Checks (After a week of no issues)
newServer({
  address = "127.0.0.1:5353",
  name    = "unbound",
  pool    = "recursor",

  healthCheckMode = "lazy",
  checkInterval = 30,
  lazyHealthCheckThreshold = 20,
  lazyHealthCheckSampleSize = 100,
  lazyHealthCheckMinSampleCount = 10,

  maxCheckFailures = 3,
  rise = 2
})