DNS on VPN or how I left PiHole

Table of Contents

Okay, the title is somewhat clickbaity: As of this moment, I have not left PiHole on all my devices. The idea is to migrate piece by piece, so that I can test on one system and then replicate that on the rest of my infrastructure.

What is PiHole and why I messed it up

PiHole is a great system to do several things:

  • DNS server - it serves DNS requests to the entire network
  • Ad-block - you can upload blocklists of domains that will not be resolved
  • DHCP server - To better set up your LAN, you can set PiHole as a DHCP server and serve clients as well

Now, I never used the DHCP part, that one is controlled by my routers. What I really need is a way to serve DNS records and a way to block bad domains that I come across. What I do not need is a flashy UI showing me the history of my DNS requests or the blocklists. I need something that can be tuned end-to-end through an SSH shell and runs on localhost by default.

Now, where I messed up is twofold: Firstly, I did not set up PiHole as a recursive DNS server, only as a proxy to some non-Google server. This is bad, because the upstream server will see all my requests, the only value-add at that point is the fact that it can block ads. The second one was purely of my own doing: I run PiHole inside an LXC container, pre-built, pre-configured. This should work just fine, right? Well, in my case, the LXC just picks a random time and then turns my entire Proxmox system into a vegetable. Always at different times. Sometimes it takes a day, sometimes it’s two weeks, but the system will always, without fail… fail. It started getting so bad that I resorted to keeping two VPN profiles: with the PiHole and with 9.9.9.9 as the DNS server.

The DNS overhead…

is almost non-existent. If I assume that my PiHole ran happily on 1 core and 512MB of RAM, with all the UI and webservers added to it, it should not be an issue to run a UI-less DNS server on a VPS should not be that much of an issue, right? Especially if it has 2 cores and 4GB of RAM!

That’s what I thought up: My VPN server already serves me all the traffic and does not seem to be doing much, so why not run a DNS server on it as well?

What DNS resolver and server to choose

From some looking around, I found several options, but in the end, I went with something that I found would provide good performance and config: Knot-resolver. I am quite partial to the config; to get recursive DNS resolution, I only had to change one line in the config and one line in my VPN client.

net.listen('10.10.5.1', 53, { kind = 'dns' }

If 10.10.5.1 is your VPN server address, if you include this, congrats. Your knot-resolver now serves on your VPN network. Not much to it, huh?

This is why my DNS server of choice is Knot-resolver. However, knot-resolver does not include “block lists” by default, and setting up /etc/hosts would either give me a huge hosts file or just not block ads. We need to fix that, which is where the next tool comes in: Knot.

Intermission: Why two servers?

Well, from what I understood during my brief research, there are two types of DNS servers. One is the “resolver” kind. This is what 1.1.1.1 does. You send a domain name, and it returns an IP address. A DNS resolver does not need to do much more than that, it just processes requests. If your server is recursive, it asks the TLD DNS servers and goes from there; if not, you have an upstream server that does.

The other is an “authoritative” server. From what I understood, this one does not necessarily have to do lookup for all possible domains you may throw at it, but it does have a set of domain names and IP addresses mapped. It provides a predefined set of domains that it will return.

Now, you do not need two servers. If your homelab is covered by a “real” address (such as m4iler.cloud), you do not need an authoritative DNS server. You can just use the DNS records you set up publicly, and in my case, those would be useless to an attacker, since my systems run mostly on private addresses. Your mileage may vary, and I may get to a point where I run stuff on a dedicated domain, use SSL keys for everything, and do it the way it’s supposed to be. For now, though, I like my .lan domain too much to give that up.

Tying the knot

To set up the Knot-DNS authoritative server, you can just follow the steps outlined in the installation manual. After that, the file you want to edit is /etc/knot/knot.conf or whichever file you want to use as the configuration.

One important thing that might trip you up is the fact that both Knot and Knot-resolver want to be used as the primary DNS server. This means both want to tie up the UDP/53 port for themselves. Since I use the resolver as the main system and the authoritative server as a backend for a select records, I put Knot on port 54 and told knot-resolver to ask it when my domain pops up.

This is the final config of Knot-DNS:

server:
    rundir: "/run/knot"
    user: knot:knot
    automatic-acl: on
    listen: [ 127.0.0.1@54, ::1@54 ]

log:
  - target: syslog
    any: info

database:
    storage: "/var/lib/knot"

remote:
#  - id: secondary
#    address: 192.168.1.1@53
#
#  - id: primary
#    address: 192.168.2.1@53

template:
  - id: default
    storage: "/var/lib/knot"
    file: "%s.zone"

zone:
#    # Primary zone
  - domain: lan.
    template: default

The database entry points to a folder where our zones can be inserted. This is roughly what it looks like:

$ORIGIN lan.
$TTL 3600

@       IN SOA  dns.lan. root.lan. (
            2025010101  ; serial
            3600        ; refresh
            900         ; retry
            604800      ; expire
            86400       ; minimum
        )

        IN NS  dns.lan.

; Authoritative DNS server
dns     IN A   127.0.0.1  ; your server running Knot

; Your hosts
muh-server  IN A   10.10.5.10
immich.muh-server IN CNAME muh-server
jellyfin.muh-server IN CNAME muh-server
home.muh-server IN CNAME muh-server
music.muh-server IN CNAME muh-server

With this configuration, I have one server set up as an A record (muh-server.lan), the rest are CNAME records. What does this mean? It means that I have one record, and one record only, for the server, and the rest just points to it. Think of it as several links pointing to the same file. If I change the A record, all the CNAME records follow. This is great, because in the event I switch my server IP, I can simply edit one line and the whole infrastructure goes back on the road. This also means that I can replicate the same config on multiple tunnels (which my server is connected to) and use the exact same namespace with just one IP change.

Making the two play along

Now that we have the Knot-DNS running on localhost:54/UDP and knot-resolver running on the tunnel IP on port 53, how do I get them to communicate? If I send a request for muh-server.lan, the authoritative server knows it is there, but how does it tell the resolver?

policy.add(policy.suffix(
    policy.STUB({
        "127.0.0.1@54"
    }),
    { todname("lan.") }
))

If you put this at the end of your kresd.conf, whenever the top level domain .lan is requested, it asks the authoritative server on localhost:54/UDP.

Sure, you now have to maintain two tools, but it’s still just two tools for the benefit of a lighter, more robust system. From what I understand, the memory footprint should be negligible, but I have yet to measure it on a long-term basis.

What about ad-blocking?

For this, I had to dig a bit in the documentation. For someone used to the systems, you may see me as an idiot, but it is a really nice feature: Knot-resolver uses the policy.add() function to block DNS requests based on a blocklist. To activate a blocklist, I just added three lines to my config:

policy.add(
    policy.rpz(policy.DENY_MSG('domain blocked by your resolver operator'),
               '/etc/knot-resolver/blocklists/rpz.db',
               true))

When this is saved and the service restarted, Knot-resolver will refer to this file and return the error message if a file is on the list. Could I have set up the DNS records this way? Possibly. Was it easier to do at 2AM with two tools? Definitely.

The way the RPZ file looks is as follows:

crash[.]163[.]com CNAME .

This domain will never resolve to an IP address. This brings us to the last point: Where and how do we get the DNS records to actually block? PiHole has it set up nicely: You add a URL, press Enter and PiHole is a few hundred domains richer.

This is where some Bash magic can come in handy. I managed to slap and duct-tape together the following:

#!/bin/bash

BLDIR="/etc/knot-resolver/blocklists/lists"

curl -L https://adaway.org/hosts.txt -o "$BLDIR/adaway.txt"
curl -L "https://gist.githubusercontent.com/wassname/b594c63222f9e4c83ea23c818440901b/raw/1b0afd2aecf3a099f1681b1cf18fc0e6e2fa116a/Samsung%2520Smart-TV%2520Blocklist%2520Adlist%2520(for%2520PiHole)" -o "$BLDIR/samsung.txt"
curl -L https://raw.githubusercontent.com/imkarthikk/pihole-facebook/master/pihole-facebook.txt -o "$BLDIR/facebook.txt"
curl -L https://raw.githubusercontent.com/kboghdady/youTube_ads_4_pi-hole/master/youtubelist.txt -o "$BLDIR/youtube.txt"
curl -L https://raw.githubusercontent.com/Perflyst/PiHoleBlocklist/master/android-tracking.txt -o "$BLDIR/android.txt"
curl -L https://raw.githubusercontent.com/Perflyst/PiHoleBlocklist/master/SmartTV.txt -o "$BLDIR/smartTV.txt"
curl -L https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts -o "$BLDIR/hosts2.txt"
curl -L https://v.firebog.net/hosts/Easyprivacy.txt -o "$BLDIR/easyprivacy.txt"
curl -L https://www.github.developerdan.com/hosts/lists/tracking-aggressive-extended.txt -o "$BLDIR/tracking.txt"

BLROOT="/etc/knot-resolver/blocklists"
OUT="$BLROOT/rpz.db"

echo ";" > "$OUT"   # Reset file with comment

# Convert hosts-style lists inside lists/
for f in "$BLROOT/lists/"*; do
    grep -E '^[0-9]' "$f" | awk '{print $2 " CNAME ."}' >> "$OUT"
done

systemctl restart kresd@1.service

Now, if I may explain the script:

The BLDIR is where the individual blocklsits will be downloaded to. After that, all blocklists are curl’d individually (yes, it’s sloppy) and put into the directory with their own names. You can use a random name for each, but I don’t have that many blocklists yet.

Underneath, you can see that $BLROOT is declared as the output file, and $OUT is the rpz.db file that knot-resolver will work with. Next is a beautiful thing that I had to do a lot of ChatGPT’ing for. It’s a simple awk, but I cannot awk to save my life. This is necessary because some files include their format as the /etc/hosts format. This is what it looks like: 127.0.0.1 muh-server.lan Knot-resolver cannot use this, the format for knot-resolver is what I showed you earlier, a CNAME to ..

The awk goes through every file, searches for a hosts-file looking line, and replaces it with the proper format. Then it appends to the $OUT file and restarts the knot-resolver service.

Optimizations for the future

In the future, I would like to make the rpz.db slimmer by deduplicating the records. As it stands, if a record is in every file I download, it will make the file unnecessarily large. What I can do is add a small sort -u at the end to really bring it all together, but that is for some other time.

So far, I am very happy with this system. My server is humming away happily, and whenever my VPS is online, I know my DNS records will resolve. Even when my home server dies, I still have adblock enabled on my devices. This is much better for me than to have “nice-to-have” services and “critical” services on one box. What’s critical goes in one place, possibly backed up. What’s not, can go in a separate box, unless it interferes with the critical.

Next steps

In the coming weeks/months, I want to move from PiHole at my parents’ house and replace PiHole for good. I could even get it running via Git, so the rpz file can be managed from one place. Those are probably questions for another day.

I hope you learned something in this, I sure did. I learned that it’s always DNS, but not always in a bad way. Knot-resolver and Knot work quite well, if you have a different favourite, I’d love to hear about it!