Sinkholing with PowerDNS Recursor
For some time I've been meaning to set up a Pi-hole to drop advertising and malware sites by using DNS.
One of the things that has stopped me has been the size of the tech stack that Pi-hole uses - I don't really want to pick up a webserver, control panel and dnsmasq just to do some fancy DNS recursion.
I also have a Kubernetes cluster, so I'm gonna work my way through running PowerDNS Recursor in a container in Kubernetes, and then setting it to sinkhole sites.
Running PowerDNS Recursor
This is pretty straightforward. I've created a minimal Dockerfile intended for use with Kubernetes, and put it on DockerHub. I used buildah to build the image, since all my nodes have cri-o on rather than Docker.
Next, I'll run my
pdns_recursor container in Kubernetes. We'll use a
Deployment to ensure the container is running as a pod, and then a
Service with a LoadBalancer to expose port 53 on both UDP and TCP. The
config for the recursor will live in a ConfigMap and get mounted into
the pod as a Volume.
I've put all the kubernetes configs I'm using into a Gist, and will
only excerpt the interesting bits below.
So, here's the pod spec for the deployment:
spec: volumes: - name: recursor-config-volume configMap: name: recursor-config containers: - image: docker.io/thommay/pdns_recursor:4.3.1-1 imagePullPolicy: IfNotPresent name: recursor ports: - containerPort: 53 protocol: UDP name: udp-dns - containerPort: 53 protocol: TCP name: tcp-dns - containerPort: 8082 name: metrics volumeMounts: - name: recursor-config-volume mountPath: /srv/config
recursor-config ConfigMap goes a very trivial
local-address=0.0.0.0, :: disable-syslog=yes # so that our logs show up in the pod's logs webserver=yes # for prometheus metrics scraping webserver-address=0.0.0.0
We'll finish off with a pair of LoadBalancer services, for TCP and UDP.
With these running, we can use
dig to test our recursor:
> dig Adsatt.go.starwave.com @192.168.1.232 +short adimages.go.com.edgesuite.net. a1412.g.akamai.net. 220.127.116.11 18.104.22.168
Great, we have a working DNS resolver.
Lots of kind people maintain blocklists of unsavoury sites. A good list of them is maintained on the Firebog, along with some basic validation. You'll probably also want to pick up anudeep's permitted list.
PowerDNS allows us to script the recursor with Lua, and we'll use this to import the block lists and check the queries against them.
I wrote a very trivial Rust binary to do this for me: here's the source. You give it a list of blocklists, a permit list and you get two files containing Lua arrays back.
With those two files, we can write a Lua script that reads them, checks the query against the lists, and manipulates the result accordingly. Here's the script:
adservers=newDS() permitted=newDS() function preresolve(dq) if permitted:check(dq.qname) or (not adservers:check(dq.qname)) then return false end if(dq.qtype == pdns.A) then dq:addAnswer(dq.qtype, "127.0.0.1") elseif(dq.qtype == pdns.AAAA) then dq:addAnswer(dq.qtype, "::1") end return true end adservers:add(dofile("/srv/config/blocklist.lua")) permitted:add(dofile("/srv/config/permitted.lua"))
We'll add that script and the two generated lists to the
recursor-config ConfigMap, and we'll append
recursor.conf. Once that's done, we'll restart the recursor
kubectl rollout restart deployment/recursor.
Let's run the same
dig command we used earlier to check that this
> dig Adsatt.go.starwave.com @192.168.1.232 +short 127.0.0.1
Perfect! By returning
localhost, we block the
offending site. Our browser (or other application) will issue a
request to the machine it's running on, and should get a very
fast negative response back.
There's one problem with what we have currently: it'll get out of date as new adware and malware sites appear. So let's ensure that we get a daily update of our lists.
First, we're going to run the blocklister I introduced above as an
This is a container that runs before the normal
container, and can be used to set up the pod. In our case, it'll
create the block lists. We'll use an
EmptyDir volume to share the
configuration between the InitContainer and the recursor.
Here's the InitContainer spec to add to our Deployment:
initContainers: - name: blocklister image: docker.io/thommay/blocklister:latest command: ["/usr/local/bin/blocklister","/srv/blocklister/config.toml"] volumeMounts: - name: blocklister-config-volume mountPath: /srv/blocklister - name: data mountPath: /srv/data
Now, every time our deployment starts up, it'll generate the configs we
need. We'll also need to fix
adblock.lua to point at the freshly generated lists.
For our final trick, we'll use a CronJob to restart the Deployment
at 3am every day.
For this to work, we have to create a ServiceAccount, give it permissions to restart the Deployment, and then create a CronJob and get it to use the ServiceAccount. The complete config is in the Gist, but let's look at the CronJob:
apiVersion: batch/v1beta1 kind: CronJob metadata: name: recursor-restart namespace: default spec: concurrencyPolicy: Forbid schedule: '0 3 * * *' # 3am daily jobTemplate: spec: backoffLimit: 2 activeDeadlineSeconds: 600 template: spec: serviceAccountName: recursor-restart restartPolicy: Never containers: - name: kubectl image: bitnami/kubectl command: ["kubectl","rollout","restart","deployment/recursor"]
We ensure we're using the
serviceAccountName we've already configured,
run the Job on a schedule, and then use a containerised copy of
kubectl to perform a rolling restart of the deployment.
And that's it - we've got a fast local recursor that's automatically updated at 3am nightly with the latest blocklists.
We've created a containerised version of PowerDNS Recursor, and run it in Kubernetes. We've then built a tool to generate blocklists in the format we need, and caused it to be run every night. We've now got a sinkhole server on our local network, running a tech stack that we fully control.