RIP Pi-hole?
Last updated on

Home DNS with BIND -- plus adblocking!


The background

The outage

Back in late October of 2024, I basically took down my home network’s services due to a DNS outage. You see, on the machine running my DNS and DHCP server, I decided to switch from networkd to NetworkManager. Unfortunately, I didn’t set the connection to autoconnect, so, upon reboot, no network, no DHCP/DNS! Additionally, since this server is a Raspberry Pi Zero 2 W, recovery would involve editing the configuration off the SD card. I didn’t have a microSD card reader handy, hence the outage.

Pi-hole

At the time, I was running Pi-hole as my DHCP and DNS solution. I had a few features I enjoyed:

  • DNS block lists for ads/malware
  • Pause button for DNS blocking
  • DHCP static leases, with hostnames that resolve DNS-side
  • User-provided DNS records (A and CNAME)
  • Easy to configure via UI

Pi-hole uses dnsmasq for its DHCP and DNS needs. The configuration done in the Pi-hole UI results in corresponding dnsmasq configuration.

The problems:

  • If it goes down, no backup.
    • Well, I could have done automated backups.
  • Even with backups, still not exactly seamless to redeploy and restore.
  • No DNS or DHCP failover to a backup Pi-hole instance I had running.

To keep such an outage from happening again, I needed a solution that addressed these problems, without losing out on the features I enjoyed.

My homelab

Final bit of background — my homelab!

  • Ansible for automation
    • So adding or changing/configuration services is done by editing their configurations and Ansible inventories, and running Ansible playbooks.
  • A local Forgeho instance for git, hosting Ansible configurations
  • Semaphore UI for manual and automated running of playbooks
  • Docker (compose) for defining services
  • Network shares for persistent storage of services’ data (specifically ZFS datasets and NFS shares)

The plan

BIND for DNS and Kea for DHCP! Though Kea is for another post.

BIND 🔗 is the definitive DNS server, so going with it, if it fit the bill, seemed like a good idea!

BIND is entirely configured via configuration files. These can be kept in source control and easily deployed wherever needed. This covers ease of configuration, backups, and, of course, user-provided records.

Kea can synchronize DHCP leases to DNS servers. 🔗 Though, at least for the forseeable future, I decided to not do this. I have no need to resolve dynamic leases, and keeping static leases and A records in sync across two files is easy enough. This covers, though manually in my case, resolving static leases.

BIND has a feature called Response Policy Zone rewriting 🔗 that can accomplish the DNS blocking feature. We have to do the legwork of providing our own blocklist, though.

Indeed, BIND seems to fit all of my requirements!

The implementation

Of course, this will all be very specific to my needs and homelab, but hopefully it’s useful to others!

BIND and Docker

The main image one would use is the official internetsystemsconsortium/bind9 image 🔗. However, since I use low-powered ARM SBCs for my DNS and DHCP servers, I can’t use this image, as it only supports amd64.

Luckily, Canonical has the ubuntu/bind9 image 🔗, which supports arm64! The only additional hiccup was that one of my SBCs with only 512MB of memory was running a 32-bit OS, so I had to reimage to a 64-bit version — further adding to downtime.

Docker Compose

Here’s my docker-compose:

version: '3'
services:
  bind:
    image: ubuntu/bind9:9.18-22.04_beta
    restart: unless-stopped
    entrypoint:
      - /bin/bash
      - -c
      - |
        useradd -u 3001 someuser || echo User someuser exists;
        rndc-confgen -a
        chown someuser:someuser /etc/bind/rndc.key
        chown someuser:someuser /var/cache/bind
        /usr/local/bin/docker-entrypoint.sh
    ports:
      - 53:53/udp
      - 53:53/tcp
    environment:
      - TZ=UTC
      - BIND9_USER=someuser
    volumes:
      - /home/someuser/deploy/services/bind/config:/etc/bind
      - /home/someuser/deploy/config/bind:/deployedconfigs

I overwrote the image’s entrypoint to get a few things working in my case.

  • For NFS permissions reasons, I need BIND to run under a specific UID/GID
  • To update the DNS block list, we’ll use rndc 🔗 to configure BIND at runtime. This requires generating a key (that will only be used locally, incidentally).

As for the volumes, /etc/bind contains BIND’s configurations as one would expect. /deployedconfigs is how we’ll provide an updated DNS blocklist.

Otherwise, the rest should be relatively self-explanatory.

Configuring BIND

Be sure to view the 🔗 documentation 🔗 for full configuration details!

Let’s start with named.conf.

include "/etc/bind/named.conf.options";
include "/etc/bind/named.conf.local";

include "/etc/bind/rndc.key";
controls {
      inet 127.0.0.1 port 953 allow { 127.0.0.1; } keys { "rndc-key"; };
};

Note that the final include and controls block are for remote configuration of BIND, which we’ll use for updating the DNS blocklist at runtime. The key is generated in our modified docker-compose entrypoint via rndc-confgen -a. We’ll run rndc via docker exec, so we only need localhost access.

As for named.conf.options:

acl "acl_trusted_clients" {
	192.168.0.0/16; //this probably doesnt get hit due to bridge, so, if we cared, we could use macvlan or whatever
	172.0.0.0/8; //docker ranges
};

options {
	directory "/var/cache/bind";

	forwarders {
		8.8.8.8;
		8.8.4.4;
	};

	//========================================================================
	// If BIND logs error messages about the root key being expired,
	// you will need to update your keys.  See https://www.isc.org/bind-keys
	//========================================================================
	dnssec-validation auto;
	version "bruh";

	allow-query {
		acl_trusted_clients;
	};

	allow-query-cache {
		acl_trusted_clients;
	};

	allow-recursion {
		acl_trusted_clients;
	};

	include "/deployedconfigs/adblock_config";
};

This should be mostly self-explanatory. The only peculiar part is the adblock_config include. This is done as an include to allow for temporarily disabling DNS blocking. As a sneak-peek, it looks like this:

response-policy {
	zone "adblock";
};

Next, named.conf.local:

zone "mydomain" {
    type master;
    file "/etc/bind/zones/mydomain.zone";
};

zone "adblock" {
    type master;
    check-names ignore;
    file "/deployedconfigs/adblock.rpz";
    allow-query { none; };
};

This should be pretty self-explanatory, aside form the contents of the zone files. Actually, it’s worth noting the blocklist we’ll use has domains containing technically illegal characters (underscore? can’t remember). This would normally cause BIND to fail to run, but we can relax the requirements with check-names ignore;.

Main zone file

Again, the documentation 🔗!

Here’s, more or less, my main zone file.

$TTL	1d
$ORIGIN	mydomain.

@						IN	SOA		dns2 mail.stub (
									2024102917	; Serial
									1d			; Refresh
									2h			; Retry
									1000h		; Expire
									2d			; Minimum
									)
						IN	NS		dns2	; primary
						IN	NS		dns1	; secondary

dns2					IN	A		192.168.1.2
dns1					IN	A		192.168.1.3

; hosts that get reverse-proxied
dns1-secure			5m	IN	CNAME	portal
dns2-secure			5m	IN	CNAME	portal
food				5m	IN	CNAME	portal
; ... etc

; static leases or manually configured IPs
switch				5m	IN	A		192.168.1.4
vpn					5m	IN	A		192.168.1.200
nas					5m	IN	A		192.168.1.201
router				5m	IN	A		192.168.1.1
; ... etc

Again, all pretty self-explanatory, especially if one runs DiG with any frequency. Of course, as a full-fledged DNS server, any records can be configured.

As for the DNS blocklist zone file, we’ll generate it later, but it looks something like this:

$TTL 1d
$ORIGIN adblock.
@ IN SOA @ mail.stub (
        2025022000 ; Serial
        1d         ; Refresh
        2h         ; Retry
        1000h      ; Expire
        2d         ; Minimum
        )
  IN NS dns2.mydomain.

ad-assets.futurecdn.net IN A 0.0.0.0
ad-assets.futurecdn.net IN AAAA ::
ck.getcookiestxt.com IN A 0.0.0.0
ck.getcookiestxt.com IN AAAA ::
eu1.clevertap-prod.com IN A 0.0.0.0
eu1.clevertap-prod.com IN AAAA ::
wizhumpgyros.com IN A 0.0.0.0
wizhumpgyros.com IN AAAA ::
coccyxwickimp.com IN A 0.0.0.0
coccyxwickimp.com IN AAAA ::

According to past research, versus simply NXDOMAINing lookups, 0.0.0.0 tends to work better. 🤷

Automatically updating DNS blocklist

Here’s my Ansible runbook, more or less.

- name: Generate rpz file
  hosts: localhost
  tasks:
    - name: Create temp file
      ansible.builtin.tempfile:
        state: file
        prefix: adblock.
        suffix: .rpz
      register: temp_file
    - name: Generate rpz entries
      ansible.builtin.copy:
        dest: '{{ temp_file.path }}'
        mode: u=r,g=r,o=r
        content: |
          $TTL 1d
          $ORIGIN adblock.
          @ IN SOA @ mail.stub (
                  {{ '%Y%m%d%H' | strftime(second=ansible_date_time.epoch, utc=True) }} ; Serial
                  1d         ; Refresh
                  2h         ; Retry
                  1000h      ; Expire
                  2d         ; Minimum
                  )
            IN NS dns2.mydomain.

          {% set hosts =
            lookup('ansible.builtin.url', 'https://raw.githubusercontent.com/StevenBlack/hosts/refs/heads/master/hosts', wantlist=True)
            | select('regex', '^0.0.0.0')
            | map('regex_search', '^[^ ]+ ([^ ]+)$', '\\1')
            | flatten
            | unique
            | reject('eq', '0.0.0.0')
          %}
          {% for host in hosts %}
          {{ host | regex_replace('(.+)', '\1 IN A 0.0.0.0') }}
          {{ host | regex_replace('(.+)', '\1 IN AAAA ::') }}
          {% endfor %}

- name: Apply rpz file to host
  hosts: dns
  tasks:
    - name: Ensure folder exists
      ansible.builtin.file:
        path: ~/deploy/config/bind/
        state: directory
        mode: u=rwx
    - name: Copy rpz file to host
      ansible.builtin.copy:
        src: '{{ hostvars.localhost.temp_file.path }}'
        dest: ~/deploy/config/bind/adblock.rpz
        mode: u=r,g=r,o=r
    - name: Force update adblock_config
      ignore_unreachable: true
      ansible.builtin.copy:
        dest: ~/deploy/config/bind/adblock_config
        mode: u=r,g=r,o=r
        content: |
          response-policy {
            zone "adblock";
          };
        # originally was false in order to allow other workflows full control
        # made it true just in case such a workflow fails and leaves adblock off
        force: true
    - name: Refresh bind
      ignore_unreachable: true
      community.docker.docker_container_exec:
        container: services-bind-1
        argv:
          - rndc
          - reload
          # - adblock # no longer picking adblock zone specifically, since we might be changing response-policy, as well

There are plenty of resource online for turning such lists into zone files. This one is custom made by yours truly to work natively in Ansible with the power of Jinja templates!

After we generate the file, we mainly just copy it to each host and run rndc reload.

In order to temporarily disable DNS blocking, we have that additional include "/deployedconfigs/adblock_config"; from before. When DNS blocking is enabled, the response-policy block will be present. When disabled, it’ll be removed (or commented out).

BIND does not watch files for updates and requires external signals like rndc commands (or zone transfers) to update at runtime. This is why we run rndc reload at the end. Since we use Docker, docker exec is the easiest way to run it.

The only othe behavior of consequence is that we force DNS blocking back on when we update the blocklist. This doesn’t have to be done, but doing it helps in case we fail to enable it after temporarily disabling it.

I have this runbook added to Semaphore UI, scheduled to run every few hours.

Temporarily disabling DNS blocklist

It looks something like this:

- name: Temporarily disable adbock
  hosts: dns
  ignore_errors: true
  ignore_unreachable: true
  tasks:
    - name: Disable adblock via config
      ansible.builtin.copy:
        dest: ~/deploy/config/bind/adblock_config
        mode: u=r,g=r,o=r
        content: |
          // response-policy {
          //   zone "adblock";
          // };
    - name: Refresh bind
      community.docker.docker_container_exec:
        container: services-bind-1
        argv:
          - rndc
          - reload

- name: Wait
  hosts: localhost
  tasks:
    - name: Wait
      ansible.builtin.pause:
        minutes: '{{ disable_minutes | default(1) }}'

- name: Enable adbock
  hosts: dns
  ignore_errors: true
  ignore_unreachable: true
  tasks:
    - name: Enable adblock via config
      ansible.builtin.copy:
        dest: ~/deploy/config/bind/adblock_config
        mode: u=r,g=r,o=r
        content: |
          response-policy {
            zone "adblock";
          };
    - name: Refresh bind
      community.docker.docker_container_exec:
        container: services-bind-1
        argv:
          - rndc
          - reload

Pretty self-explanatory (well, along with the prior explanation)!

The excessive use of ignore_errors and ignore_unreachable is to reduce the chance that we leave DNS blocking disabled due to some transient error.

I have this runbook added to Semaphore UI, and I need only click a couple buttons to temporarily disable DNS blocking on my network.

Secondary DNS server

BIND does have the concept of primary-secondary authoritative name servers 🔗. We don’t need this, however. We don’t need to use zone transfers to keep separate servers synchronized. Instead, since we use static configs, we just need to deploy another instance of BIND somewhere else. Bam no problem!

The end

The end! Basically!

Of course, there’s the matter of configuring DHCP to advertise both DNS servers.

And there’s the future post about setting up Kea for home DHCP.

For now, we’ve at least got a functioning DNS server!