DNS Firewall for PowerDNS

Before we start: Spamhaus account, IP addresses

To start using the Spamhaus DNS Firewall, you must set up your account. If you haven’t done so, sign up at https://www.spamhaus.com/free-trial/free-30-day-trial-for-dns-firewall-threat-feeds/ and validate your email address to complete your account setup.

Once your account has been set up, you need to register the IP addresses of your PowerDNS server at https://portal.spamhaus.com/dns-firewall/. You can add both IPv4 and IPv6 addresses. Adding the IP addresses opens up access to the Spamhaus DNS servers from your server.

Browser Image

Installing PowerDNS on your server

When installing the PowerDNS Recursor on your server, the best practice is to use the PowerDNS repositories at https://repo.powerdns.com if possible. The PowerDNS team builds and distributes packages for Debian, Ubuntu, and RPM-based (RedHat Enterprise Linux, CentOS, …) Linux distributions.

Using this repository rather than the standard repository for your distribution guarantees that you will have access to the latest versions, features, and bug fixes. They are well-tested, used by large organizations, and supported by the community.

Ensure you install the latest stable release of the “PowerDNS Recursor” packages for your distribution.

Creating a local RPZ zone

Once PowerDNS is installed, locate and open the PowerDNS Recursor configuration file. On our Ubuntu server, the configuration file is located on the path /etc/powerdns/recursor.conf. Check whether lua-config-file is defined. If not, add the following statement (adjusting the path to your environment):

lua-config-file=/etc/powerdns/recursor.lua

Open the recursor.lua config file mentioned above. Unless you’ve modified the default configuration, it has a single dofile() statement to keep the DNSSEC trust anchors up to date.

In this file, we will define and configure the RPZ zones. Please make sure the DNSSEC-related dofile() statement is left in place.

At the bottom of the recursor.lua file, add the following statement to load a local RPZ zone (again adjusting the path to suit your environment):

rpzFile("/etc/powerdns/basic.rpz", {})

Close the LUA file and open the basic.rpz file we’ve just defined. This file will include all the blocks and allow-lists that are separate from the DNS firewall feeds we will enable later. An example file would be as follows:

$TTL 300

@     SOA localhost.local.rpz. hostmaster.example.com (

          1           ; Serial number
          1m          ; Refresh every minute
          1m          ; Retry every minute
          5d          ; Expire in 5 days
          1m )        ; Negative caching ttl 1 minute
      NS LOCALHOST.

spamhaus.com              CNAME rpz-passthru.
*.spamhaus.com            CNAME rpz-passthru.
32.100.113.0.203.rpz-ip   CNAME rpz-passthru. ;allowlist 203.0.113.0/32
example.net               CNAME .             ;local block against example.net
*.example.net             CNAME .             ;local block against *.example.net

After you’ve made these changes, restart PowerDNS and check the log files or system journal for messages indicating it has loaded the local RPZ file. In my example (using Ubuntu 22.04 LTS and PowerDNS Recursor 4.8):

Oct 26 11:56:57 ubuntu pdns-recursor[2183]: msg="Loading RPZ from
file" subsystem="luaconfig" level="0" prio="Info" tid="0"
ts="1666785417.604" file="/etc/powerdns/basic.rpz"
Oct 26 11:56:57 ubuntu pdns-recursor[2183]: msg="Done loading RPZ
from file" subsystem="luaconfig" level="0" prio="Info"
tid="0" ts="1666785417.605" file="/etc/powerdns/basic.rpz"

Verify the local RPZ file functions properly by querying example.net. Please note that you will not get an A record: instead, you will receive a reply with the status code NXDOMAIN.

doc@ubuntu:~$ dig example.net @localhost

; <<>> DiG 9.18.1-1ubuntu1.2-Ubuntu <<>> example.net @localhost
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: **NXDOMAIN**, id: 19977
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;example.net. IN A

Loading Spamhaus RPZ zones

Now that we’ve enabled basic RPZ functionality let’s try loading a few of the available RPZ feeds.

As RPZ zones can be pretty big, we’ll use the IXFR transfer mechanism to enable incremental zone updates. This saves on bandwidth and I/O load.

For each zone we wish to add, we’ll need to add an rpzPrimary() statement to our recursor.lua file. These statements are in the following format:

rpzPrimary("192.0.2.1", "rpz.example.net",
{optionalSetting="somevalue"})

The first parameter is the IP address of the remote DNS server. In this case, it will be one of the Spamhaus DNS servers. You can find a list of IP addresses at https://docs.spamhaus.com/dns-firewall/docs/source/configuration/distribution-servers.html. You can add multiple servers (for resiliency) by defining them as a list instead of a single server. We’ll include a few examples below.

The second parameter is the name of the RPZ zone. Make sure it exactly matches one of the zones published by Spamhaus.

The third parameter is a list of options, allowing us to provide additional config settings. This parameter is optional. We’ll suggest a couple of interesting settings later in this guide.

In our example, we’ll use the Spamhaus’ Amsterdam DNS server and load the ‘malware.host.dtq’, ‘adware.host.dtq’ and ‘porn.host.srv’ feeds. This last feed is very large, and downloading the file zone can take some time. PowerDNS Recursor will timeout the transfer after 20 seconds, which wasn’t enough to load this zone on my test server. To avoid this timeout, I added a configuration option to increase the transfer timeout to 180 seconds. This setting may need tweaking by the user.

rpzPrimary({"2606:700:a:202::c7a8:5a33", "82.94.236.67"},
"malware.host.dtq")

rpzPrimary("82.94.236.67", "adware.host.dtq")

rpzPrimary("82.94.236.67", "porn.host.srv", {axfrTimeout="180"})

After adding these statements to the recursor.lua file and reloading PowerDNS, we get a lot more log entries indicating that PowerDNS has loaded these remote zones:

Oct 26 12:16:42 ubuntu pdns-recursor[2273]: msg="Loading RPZ from
nameserver" subsystem="rpz" level="0" prio="Info" tid="0"
ts="1666786602.996" primary="82.94.236.67" zone="adware.host.dtq"

Oct 26 12:16:42 ubuntu pdns-recursor[2273]: msg="Loading RPZ from
nameserver" subsystem="rpz" level="0" prio="Info" tid="0"
ts="1666786602.996" primary="82.94.236.67" zone="malware.host.dtq"

Oct 26 12:16:43 ubuntu pdns-recursor[2273]: msg="RPZ load in
progress" subsystem="rpz" level="0" prio="Info tid="0"
ts="1666786603.016" nrecords="409" primary="82.94.236.67"
zone="porn.host.srv"

Oct 26 12:16:43 ubuntu pdns-recursor[2273]: msg="RPZ load in
progress" subsystem="rpz" level="0" prio="Info" tid="0"
ts="1666786603.022" nrecords="435" primary="82.94.236.67"
zone="malware.host.dtq"

Oct 26 12:16:43 ubuntu pdns-recursor[2273]: msg="RPZ load in
progress" subsystem="rpz" level="0" prio="Info" tid="0"
ts="1666786603.032" nrecords="414" primary="82.94.236.67"
zone="adware.host.dtq"

Oct 26 12:16:43 ubuntu pdns-recursor[2273]: msg="RPZ load completed"
subsystem="rpz" level="0" prio="Info" tid="0"
ts="1666786603.047" nrecords="552" primary="82.94.236.67"
soa="need.to.know.only. hostmaster.deteque.com. 1666786561 120 60
432000 60" zone="adware.host.dtq"

To test whether the zones are loaded properly and the filtering is active, we can try to resolve the adware-host-dtq.rpzfeeds.com test domain, which is listed in the adware.host.dtq zone:

doc@ubuntu:~$ dig adware-host-dtq.rpzfeeds.com @localhost

; <<>> DiG 9.18.1-1ubuntu1.2-Ubuntu <<>> adware-host-dtq.rpzfeeds.com 
@localhost
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: **NXDOMAIN**, id: 27307
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;adware-host-dtq.rpzfeeds.com. IN A
;; Query time: 0 msec
;; SERVER: 127.0.0.1#53(localhost) (UDP)
;; WHEN: Tue Nov 08 08:10:00 UTC 2022
;; MSG SIZE rcvd: 57

We get a status of NXDOMAIN, indicating that the hostname or domain doesn’t exist. This is the default reply for filtered domains.

If we perform the query for a zone that is not loaded, we do get a reply indicating that filtering is not active for that zone:

doc@ubuntu:~$ dig dga-host-dtq.rpzfeeds.com +short
199.168.88.54
doc@ubuntu:~$

Alternatively, if your PC is using your PowerDNS server as a resolver, you can navigate to the DNS Firewall section of the Spamhaus Customer Portal at https://portal.spamhaus.com/dns-firewall/, then click on the Config Check tab to check which zones are currently active. This page lists all zones available and gives a short overview of what type of content they protect you from.

Optimize setup

Now that we’ve established the basic configuration let’s optimize our setup a bit. We’ve previously mentioned the axfrTimeout parameter to increase the default zone transfer timeout, but other options can help us when using multiple large zones.

By default, PowerDNS Recursor will perform a complete zone transfer for all RPZ zones on start-up, keep the zones in memory, and perform regular IXFRs (incremental zone transfers) to keep up to date. However, as the zones aren’t persistent on disk, every restart requires PowerDNS to perform a full transfer (AXFR) of the zone. We can ask PowerDNS to keep the zone files persistent on disk to optimize this. This allows PowerDNS to use the local file on start-up and perform an incremental update. Besides reducing the load on your local server, the network and the Spamhaus servers, this also accelerates your start-up time and improves safety for your users, as the RPZ isn’t active until it is fully loaded.

In our example, we’ll activate this feature for the largest zone we have configured. To do so, we change the statement for that zone to include dumpFile (instructing PowerDNS to dump the file to disk once loaded) and seedFile (which will use the dumped file as a seed when starting PowerDNS) parameters.

rpzPrimary("82.94.236.67", "porn.host.srv", {axfrTimeout="180",
dumpFile="/var/spool/powerdns/porn.host.srv.rpz",
seedFile="/var/spool/powerdns/porn.host.srv.rpz"})

One thing to keep in mind here if your system is using the SystemD configuration system (as all modern Linux distributions do) is that PowerDNS can only persist files which are in the /var directory. In our Ubuntu setup, /var/spool/powerdns existed and has the right permissions, so we’re using that folder.

This might also be an excellent time to look at some of the other optional parameters we can specify. You can find an overview on the PowerDNS documentation page at https://doc.powerdns.com/recursor/lua-config/rpz.html#rpz-settings/. Interesting parameters are Refresh, which allows you to change the interval between checks for updates, and defpol, which enables you to change the default behaviour when a zone is listed.

Suppose we want to redirect all blocked traffic for a particular zone to a warning page hosted at ‘myblockpage.example.tld’, instead of not responding. In that case, we can change the configuration as follows:

rpzPrimary("82.94.236.67", "adware.host.dtq", {defpol =
Policy.Custom, defcontent="myblockpage.example.tld"})

Logging and Extended Error codes

Extended Error codes

In version 4.5, the PowerDNS Recursor introduced support for Extended Error codes using EDNS (RFC8914). This allows us to indicate to the client that the lookup was blocked by the DNS Firewall and provide additional info.

To configure this, we add the extendedErrorCode and extendedErrorExtra options to the rpzPrimary() statement in our resolver.lua file. See RFC8914 (https://datatracker.ietf.org/doc/rfc8914/) for a description of available error codes. Likely candidates are 15 (Blocked) and 17 (Filtered).

Example:

rpzPrimary({"2606:700:a:202::c7a8:5a33", "82.94.236.67"},
"malware.host.dtq", {extendedErrorCode = 15, extendedErrorExtra =
"Known Malware host"})

After restarting PowerDNS, we can now verify that we get the Extended Error code when querying the test domain in that zone:

doc@ubuntu:~$ dig malware-host-dtq.rpzfeeds.com @localhost
; <<>> DiG 9.18.1-1ubuntu1.2-Ubuntu <<>> malware-host-dtq.rpzfeeds.com 
@localhost
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 58099
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
; **EDE: 15 (Blocked): (Known Malware host)**
;; QUESTION SECTION:
;malware-host-dtq.rpzfeeds.com. IN A
;; Query time: 0 msec
;; SERVER: 127.0.0.1#53(localhost) (UDP)
;; WHEN: Wed Nov 02 12:28:43 UTC 2022
;; MSG SIZE rcvd: 87

If your version of dig isn’t showing the error code, it might not be recent enough, as the RFC is relatively new (2020).

Logging with protobuf

PowerDNS supports Protocol Buffers (protobuf) to stream information about queries, answers, and policy decisions. This allows you to do off-host, out-of-band processing of a large amount of queries, generate statistics, and conduct security research.

We’ll use this capability to send the blocked domains using a small daemon which will take the messages and log them.

We need to enable the functionality in PowerDNS first, indicating where (IP + port) the protobuf messages are being sent and which messages we want to send. Our example will use a protobuf receiver running on port 4242 on localhost. We are only interested in “tagged” queries (RPZ matches are tagged by default) and only in queries for A, AAA, CNAME, TXT, NS and SRV types.

To do this, we add the following statement to our recursor.lua config:

protobufServer("127.0.0.1:4242", {taggedOnly=true, logQueries=true,
exportTypes={'A', 'AAAA', 'CNAME', 'TXT', 'NS', 'SRV'} })

Multiple Protocol Buffer receiver implementations for PowerDNS exist. In our example, we’ll use https://github.com/spamhaus/pdns-logger, a logger written and maintained by Spamhaus. This receiver is pretty simple to set up and configure and can log to a log file, to Syslog, or directly to a Sqlite3 database to enable easy reports.

Please see https://github.com/spamhaus/pdns-logger for complete installation instructions. My test system is an Ubuntu server, so I built the Debian/Ubuntu .deb package according to the instructions in the Readme file.

The pdns-logger daemon can log to File, Syslog, or an SQLite database. Define which of those mechanisms is active in the /etc/pdns-logger/pdns-logger.ini config file.

In our example, we’ll use the default config file and log to '/var/log/pdns-logger/pdns.log'.

When we fire a couple of test queries for domains in one of the RPZ zones, we see them appear in the log file, while non-listed domains do not.

If you want a more advanced PowerDNS ProtoBuf DNS Collector, have a look at https://github.com/dmachard/go-dns-collector/. It supports multiple sources (including dnstap for Unbound and log files for Bind), and allows you to visualize DNS traffic errors and anomalies with a Grafana dashboard.