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:

package main

import (
	"github.com/miekg/dns"
	"log"
)

func main() {
	var serverAddr = "8.8.8.8:53"
	dns.HandleFunc(".", func(w dns.ResponseWriter, req *dns.Msg) {
		log.Printf("%+v\n",req)
		resp, err := dns.Exchange(req, serverAddr)
		if err != nil {
			dns.HandleFailed(w, req)
			return
		}
		if err := w.WriteMsg(resp); err != nil {
			dns.HandleFailed(w, req)
			return
		}

	})
	log.Fatal(dns.ListenAndServe(":53", "udp", nil))
}

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.

func localproxy(w dns.ResponseWriter, req *dns.Msg) {
	var resp dns.Msg
	resp.SetReply(req)
	for _, q := range req.Question {
		log.Printf("sinkholing req for %s", q.Name)
		a := dns.A{
			Hdr: dns.RR_Header{
				Name:   q.Name,
				Rrtype: dns.TypeA,
				Class:  dns.ClassINET,
				Ttl:    60,
			},
			A: net.ParseIP("127.0.0.1").To4(),
		}
		resp.Answer = append(resp.Answer, &a)
	}
	w.WriteMsg(&resp)
}

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:

func main() {

	dns.HandleFunc("facebook.com", forward)
	dns.HandleFunc("abc.facebook.com", localproxy)
	log.Fatal(dns.ListenAndServe(":53", "udp", nil))
}

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):

    go func() {
		log.Fatal(dns.ListenAndServe(":53", "udp", nil))
	}()

	sigsStop := make(chan os.Signal, 1)
	signal.Notify(sigsStop, syscall.SIGINT, syscall.SIGTERM)
	sigHup := make(chan os.Signal, 1)
	signal.Notify(sigHup, syscall.SIGHUP)
	done := make(chan bool, 1)

	go func() {
        reload()
		for {
			<-sigHup
			reload()
		}
	}()

	go func() {
		<-sigsStop
		log.Printf("shutting down")
		done <- true
	}()

	<-done

where reload, for now, can be as simple as what we had before:

func reload() {
    log.Printf("reload called\n")
	dns.HandleFunc(".", forward)
	dns.HandleFunc("facebook.com", forward)
	dns.HandleFunc("abc.facebook.com", localproxy)
}

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”.

type holder struct {
	Filename string
	Domains []string
}

We’ll change reload to operate on holder:

func (data *holder) reload() {
	// remove everything we currently have
	for _, domain := range data.Domains {
		dns.HandleRemove(domain)
	}

	// forward everything by default
	dns.HandleFunc(".", forward)

	readFile, err := os.Open(data.Filename)
	defer readFile.Close()

	if err != nil {
		log.Fatal(err)
	}

	fileScanner := bufio.NewScanner(readFile)
	fileScanner.Split(bufio.ScanLines)

	for fileScanner.Scan() {
		domain := fileScanner.Text()
		dns.HandleFunc(domain, localproxy)
		data.Domains = append(data.Domains, domain)
	}
	log.Printf("reloaded %d domains", len(data.Domains))
}

And in our func main we simply create the struct up front:

	data := holder{Filename: "domains.txt"}
	go func() {
		data.reload()
		for {
			<-sigHup
			data.reload()
		}
	}()

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:

	mux.m.Lock()
	delete(mux.z, CanonicalName(pattern))
	mux.m.Unlock()

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):

var logger = slog.New(slog.NewJSONHandler(os.Stdout, nil))

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:

	serverAddr := flag.String("dns","8.8.8.8:53","<ip>:<port>")
	domains := flag.String("domains","domains.txt","file containing a list of domains to sinkhole, one per line")

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).