I recently picked up a copy of Black Hat Go which touched upon something I’ve been wanting to do for a while - namely a DNS proxy that logs DNS requests and sinkholes certain domains, particularly pertaining to adverts or tracking. Now a fair bunch can be done via Browser plugins but with e.g. a phone app, router, internet-enabled device, you’re better off setting up your own DNS. I’m fully aware there’s already stuff out there that does this but I’ve been meaning to get back into golang
and this felt like a great opportunity.
We’ll focus on functionality first before adding the bells and whilstles later.
Transparent proxy
For this to be any use we’ll first want to transparently relay our requests to a DNS server of our choice (e.g. Google’s 8.8.8.8
). For this we’ll use the excellent dns package.
The POC looks like this:
go run
the above, and let’s use dig
to query:
❯ dig +noall +answer facebook.com
facebook.com. 279 IN A 157.240.13.35
❯ dig @localhost +noall +answer facebook.com
facebook.com. 275 IN A 157.240.235.35
❯ dig @localhost +noall +answer facebook.com
facebook.com. 22 IN A 157.240.15.35
There’s some load-balancing going on here but we’re looking at IPs in the same subnet so we must be doing something right.
Sinkhole
Right now we’re forwarding clients requests to a real DNS - but we also want the ability to sinkhole certain domains. Let’s start by writing this as a function. Note that a single DNS request can contain many questions, so we need to check all.
The target IP could be anything but localhost
has a nice ring to it.
Extracting the forwarding logic into its own forward
func
, we can rewrite main
as:
Note that the order in which we define those HandleFunc
will respect the domain hierarchy (as in the ordering doesn’t matter - the most-specific match is tried first before bubbling up to the top-level domain).
Config, hot reload and command-line flags
With the above we have the basic functionality down - but there’s more we can do to make this user-friendly and packageable. At the very least we’ll want to:
- write to a file a list of all the domains requested that were proxied transparently (for review)
- pass in the name of a file containing a list of sinkholed domains
- make the above cmdline flags, as well as specifying which DNS to forward requests to
List of sinkholed domains
The dns
package has a HandleRemove
method - meaning that as long as we keep track of what gets added we should able to remove them.
To get this going we’ll need to kick off dns.ListenAndServe
in its own goroutine
, and ditto for our signal handlers (we have one for Ctrl-C
essentially, and one for SIGHUP
that triggers the reload):
where reload
, for now, can be as simple as what we had before:
Let’s make sure this works by sending kill -HUP 72149
once it’s up:
2023/06/02 21:00:21 PID: 72149
2023/06/02 21:00:21 reload called
2023/06/02 21:00:40 reload called
Let’s now add the bit that reads from a file!
We start by creating a struct that will hold 2 things - a filename (ideally passed in) and a list of domains. The latter is important because we’ll want to clear our mapping on reload. To be fair we could just look at the delta between what we have and add/remove accordingly, but that’s another “exercise for the interested reader”.
We’ll change reload
to operate on holder
:
And in our func main
we simply create the struct up front:
With domains.txt
being in your local directory (the one you invoke the proxy from), containing an entry per line:
❯ cat domains.txt
abc.facebook.com
def.facebook.com
To validate this, let’s start with an empty domains.txt
, overwrite it with the above and issue a SIGHUP
. The proxy’s logs look good after the reload:
2023/06/03 09:20:21 PID: 77484
2023/06/03 09:20:21 reloaded 0 domains
2023/06/03 09:20:44 reloaded 2 domains
And testing with dig
, we’re DTRT:
❯ dig @localhost +noall +answer abc.facebook.com
abc.facebook.com. 10 IN A 127.0.0.1
Now if you’re wondering “but but… locking!”, dns
got your covered. Calls like HandleRemove
do this on our behalf so we can add and remove as we wish:
See here for source.
Log
The logging we have thus far works, but we can be smarter about it. One potential improvement would be to keep a tally of domains that were queried and automatically match those against say, a list of known bad domains (Domain Block List, perhaps like the one provided by Spamhaus). But in order to do this we don’t want to have to parse logs with fancy regular expressions. Instead we should use structured logging.
Witht his in mind, let’s use slog
. Run go get golang.org/x/exp/slog
to install, and off we go.
Let’s start by creating a module-level var - I’m guessing this isn’t idomatic golang
but given I don’t use the language I’ll pass it as a beginner’s mistake (#betterbeatsperfect):
And we can replace all log.Printf
calls with something like logger.Info("config reloaded", "domains", len(data.Domains))
Running for good measure:
{"time":"2023-06-04T15:37:31.162318+08:00","level":"INFO","msg":"starting","pid":29735}
{"time":"2023-06-04T15:37:31.16557+08:00","level":"INFO","msg":"config reloaded","domains":2}
{"time":"2023-06-04T15:37:40.248166+08:00","level":"INFO","msg":"sinkhole request","domain":"abc.facebook.com."}
{"time":"2023-06-04T15:37:50.937524+08:00","level":"INFO","msg":"forward req","domain":"stackoverflow.com."}
It’s a bit overkill but if you had say, logstash
set up to publish this to ElasticSearch
, you’d have a field day.
Flags
For the final bells and whistles! We have hard-coded both the list of domains and the DNS we wish to forward to. Let’s change that:
The code is available in all its gloryshame on GitHub.
Taking it further
As I was writing this, I realised that maybe it might make more sense to have this the other way around - sinkhole everything and only allow-list what is really needed. This is “left as an exercise to the reader” (it should be a case of swapping the forward
and localproxy
functions).
Also returning a dummy record for sinkholed domains isn’t quite enough as the client will likely try to make a connection. We can spin up a local web server that returns… nothing. This will ensure the connection from the client gets closed quickly and doesn’t hang around forever (or whatever timeout the client is set to).