Ad-blocking with Unbound Response Policy Zones (RPZs)

I recently started using Unbound and wanted to experiment with the RPZ feature, only alluded to once on this forum as far as I can tell.

Install unbound-daemon, unbound-control-setup and unbound-control from opkg or apk, depending on your OpenWrt release.

opkg install unbound-daemon unbound-control-setup unbound-control
apk add unbound-daemon unbound-control-setup unbound-control

Configure Unbound to your liking, referring to the README on Github. Then, come back and setup RPZs for ad-blocking.

Create your local RPZ file for a manual allowlist and manual blocklist.

cat <<'EOF' >/etc/unbound/rpz-local.conf
$TTL 3600
@ SOA localhost. root.localhost. 1774045021 14400 3600 86400 3600
  NS  localhost.
;
; Domains to be allowed will use "CNAME rpz-passthru."
browser-intake-datadoghq.com CNAME rpz-passthru.
*.browser-intake-datadoghq.com CNAME rpz-passthru.
; Domains to be blocked will use "CNAME ."
ads.example.com CNAME .
*.ads.example.com CNAME .
EOF

In this example, 1774045021 is the serial number that Unbound will use to recognize an update is available for the RPZ zone. When you modify this file in the future, update this value with the current epoch time (e.g. date +%s), or manually increment the number using your own scheme.

Update /etc/unbound/unbound_ext.conf to define these RPZs to Unbound.

##############################################################################
# Extended user clauses added to the end of the UCI generated 'unbound.conf'
#
# Put your own forward:, view:, stub:, or remote-control: clauses here. This
# file is appended to the end of 'unbound.conf' with an include: statement.
# Notice that it is not part of the server: clause. Use 'unbound_srv.conf' to
# place custom option statements in the server: clause.
##############################################################################

# Local rpz file will override subsequent RPZs due to earlier positioning in this
# config file.

rpz:
	name: rpz-local
	zonefile: rpz-local.conf

# Hagezi example using HTTPS transfer. See other Hagezi lists at:
# https://github.com/hagezi/dns-blocklists/tree/main/rpz

rpz:
	name: rpz-hagezi-multi-light
	zonefile: hagezi-multi-light.rpz
	url: https://raw.githubusercontent.com/hagezi/dns-blocklists/refs/heads/main/rpz/light.txt
#	rpz-log: yes
#	rpz-log-name: hagezi-light

# IPFire DBL example using AXFR/IXFR. See all available IPFire blocklists at:
# https://www.ipfire.org/dbl/how-to-use

rpz:
	name: malware.rpz.ipfire.org
	primary: xfr.dbl.ipfire.org
	zonefile: malware.rpz.ipfire.org.zone
#	rpz-log: yes
#	rpz-log-name: ipfire-malware

When the unbound service starts, it will copy all *.conf files from /etc/unbound/ to the chroot directory /var/lib/unbound/, which is why the rpz-local file is suffixed with .conf instead of .rpz.

service unbound start

Check if unbound started successfully.

logread -e unbound

Check the rpz definitions are visible in the /var/lib/unbound/ directory.

cat /var/lib/unbound/unbound_ext.conf
ls -l /var/lib/unbound/*rpz*

Use unbound-control to view the rpz zones are loaded.

unbound-control list_auth_zones

If you add a domain to the rpz-local.conf file, you must force a reload of local zone file with:

unbound-control auth_zone_reload rpz-local

N.B. Because of the chroot, you must edit the "live" file in chroot /var/lib/unbound/rpz-local.conf and /etc/unbound/rpz-local.conf (for persistence). Edit in one location, and cp to the other.

Unbound will periodically refresh the remote RPZ zones based on the refresh interval defined in the zone files. For Hagezi, this is every 12 hours. For IPFire, it is every hour (3600 seconds). There is rarely any need to restart Unbound to update a blocklist, unless you are adding or removing an rpz in the unbound_ext.conf file.

If you want to force a check for update of an AXFR/IXFR rpz (i.e. IPFire DBL), you can run:

unbound-control auth_zone_transfer malware.rpz.ipfire.org

If you want to pause DNS blocking, you can disable an rpz temporarily:

unbound-control rpz_disable rpz-hagezi-multi-light

Re-enable with:

unbound-control rpz_enable rpz-hagezi-multi-light

If you wish to enable logging of hits from the blocklists, you can uncomment the rpz-log: lines from the unbound_ext.conf file. By default, the logs will go to the system log so you may wish to define a separate log file on a USB drive for persistent storage and later review. Refer to the unbound.conf man pages for how to use the logfile: option, added to /etc/unbound/unbound_srv.conf. Logging isn't covered in this post.

In summary, I like this solution for the simple fact that the blocklists can be updated without having to restart the DNS server and disrupt the existing cache. There are also no cron jobs to manage periodic list updates. Reporting can be implemented through careful activation of the rpz-log feature.

You are invited to try it out and see if it works well for you. Unbound isn't for everyone, and the RAM required could be too much for some older routers. You can have Unbound as a recursive resolver, plain forwarder, or DNS-over-TLS forwarder. RPZs will work in all those modes:

  • Block ads and malware locally, and perform recursive lookups to root and authoritative DNS servers using unencrypted port 53.
  • Block ads locally, and forward other queries to Quad9 or Cloudflare Security for malware protection using DoT port 853.

Feedback welcome.

5 Likes

Implementing RPZ filtering increases processing overhead and latency in any resolver, Unbound included. Adblock package can minimize this impact by acting as an Unbound backend or serving various DNS resolvers within their configurations. On OpenWrt, I have been using dnsmasq as a DNS/DHCP forwarder with AdGuard Home as the primary resolver and filter.

Did you get this information from ChatGPT ?

# unbound-control rpz_disable hagezi-pro

ok

# kdig nasa.gov -p 5353

;; ->>HEADER<<- opcode: QUERY; status: NOERROR; id: 6493

;; Flags: qr rd ra; QUERY: 1; ANSWER: 1; AUTHORITY: 0; ADDITIONAL: 1



;; EDNS PSEUDOSECTION:

;; Version: 0; flags: ; UDP size: 1232 B; ext-rcode: NOERROR



;; QUESTION SECTION:

;; nasa.gov.                    IN      A



;; ANSWER SECTION:

nasa.gov.               600     IN      A       192.0.66.108



;; Received 53 B

;; Time 2026-03-24 20:22:56 -03

;; From ::1@5353(UDP) in 47.9 ms

# unbound-control rpz_enable hagezi-pro

ok

# unbound-control flush_zone nasa.gov

ok removed 1 rrsets, 1 messages and 0 key entries

# kdig nasa.gov -p 5353

;; ->>HEADER<<- opcode: QUERY; status: NOERROR; id: 1713

;; Flags: qr rd ra; QUERY: 1; ANSWER: 1; AUTHORITY: 0; ADDITIONAL: 1



;; EDNS PSEUDOSECTION:

;; Version: 0; flags: ; UDP size: 1232 B; ext-rcode: NOERROR



;; QUESTION SECTION:

;; nasa.gov.                    IN      A



;; ANSWER SECTION:

nasa.gov.               578     IN      A       192.0.66.108



;; Received 53 B                                                                  ;; Time 2026-03-24 20:23:18 -03

;; From ::1@5353(UDP) in 47.4 ms

Even if they used:

flush_zone nasa.gov β†’ rpz_disable β†’ kdig
flush_zone nasa.gov β†’ rpz_enable β†’ kdig
flush_zone ... β†’ rpz_disable β†’ kdig
flush_zone ... β†’ rpz_enable β†’ kdig
flush_zone ... β†’ rpz_disable β†’ kdig
flush_zone ... β†’ rpz_enable β†’ kdig

Would they get meaningful results on a per-query basis, let alone under cumulative load or with large blocklists?

Average latency can be misleading. The real issue lies in the p95/p99 β€” queries that aren't cached and have to traverse large lists.?

β€” Gao et al. (2014) and KΓΌhrer et al. (2015)

Does "static or slow blocklisting" affect throughput or not?