Custom DNS Name Resolver in Go

Name resolution is arguably the most important function of DNS and is critical to the runtime of networking services.

Its main purpose is to allow humans to identify devices using memorable names, and to translate these names into numbers that computers can then use for routing across the network.

The most significant problem in name resolution is the frequency of requests it has to handle. This comes hand-in-hand with other concerns such as efficiency, caching, and reliability.

There is nothing magical about name resolvers themselves - they are clients interacting with servers. Anyone can implement their own client to interact with a name server.

This is what I wanted to try out using Go.

What's there on Ubuntu?

Before moving on to an implementation of my custom name resolver let’s look at the existing tooling available on Ubuntu. After all, my plan is not to replace any of the existing utilities but to better appreciate what they do.

There are a number of standard name resolvers available on Ubuntu and other types of distributions, for instance host, dig, and nslookup. Let’s try them out by querying the DNS resource records of this website.

host

$ host www.mfrancis.dev
www.mfrancis.dev is an alias for mfrancis.dev.
mfrancis.dev has address 151.101.65.195
mfrancis.dev has address 151.101.1.195

dig

$ dig +noall +answer www.mfrancis.dev
www.mfrancis.dev.	3011	IN	CNAME	mfrancis.dev.
mfrancis.dev.		3011	IN	A	    151.101.65.195
mfrancis.dev.		3011	IN	A	    151.101.1.195

nslookup

$ nslookup www.mfrancis.dev
Server:		127.0.0.53
Address:	127.0.0.53#53

Non-authoritative answer:
www.mfrancis.dev	canonical name = mfrancis.dev.
Name:	mfrancis.dev
Address: 151.101.65.195
Name:	mfrancis.dev
Address: 151.101.1.195

I prefer host due to its sweet and simple nature, although dig is the more elaborate one. For instance, using dig we can do reverse lookups:

$ dig +noall +answer -x 185.199.109.153
153.109.199.185.in-addr.arpa. 3600 IN	PTR	cdn-185-199-109-153.github.com.

By default all of these utilities rely on the nameserver(s) configured in /etc/resolv.conf. On Ubuntu:

$ grep nameserver /etc/resolv.conf 
nameserver 127.0.0.53

This local address directs to a name server service managed using systemd. We can see that by looking up what’s listening on TCP/UDP port 53:

$ sudo netstat -tulnp | grep -E ":53 "
tcp  0  0 127.0.0.53:53  0.0.0.0:*  LISTEN  1422/systemd-resolv 
udp  0  0 127.0.0.53:53  0.0.0.0:*          1422/systemd-resolv

Instead of querying the local name server we could configure Ubuntu to go to an external service, for instance Google’s DNS name servers (8.8.8.8 and 8.8.4.4).

To locate the actual authoritative name servers for a domain we could use whois or host -C (which also resolves the IP):

$ host -C mfrancis.dev
Nameserver 216.239.34.106:
	mfrancis.dev has SOA record ns-cloud-a1.googledomains.com. cloud-dns-hostmaster.google.com. 23 21600 3600 259200 300
Nameserver 216.239.38.106:
	mfrancis.dev has SOA record ns-cloud-a1.googledomains.com. cloud-dns-hostmaster.google.com. 23 21600 3600 259200 300
Nameserver 216.239.32.106:
	mfrancis.dev has SOA record ns-cloud-a1.googledomains.com. cloud-dns-hostmaster.google.com. 23 21600 3600 259200 300
Nameserver 216.239.36.106:
	mfrancis.dev has SOA record ns-cloud-a1.googledomains.com. cloud-dns-hostmaster.google.com. 23 21600 3600 259200 300

These name servers will happily resolve requests for the .dev domain but reject everything else because they are the authority only for .dev.

$ host mfrancis.dev 216.239.34.106
Using domain server:
Name: 216.239.34.106
Address: 216.239.34.106#53
Aliases: 

mfrancis.dev has address 151.101.65.195
mfrancis.dev has address 151.101.1.195
$ host google.com 216.239.34.106
Using domain server:
Name: 216.239.34.106
Address: 216.239.34.106#53
Aliases: 

Host google.com not found: 5(REFUSED)

In addition to querying the configured name servers these tools also examine /etc/hosts which is an artifact of the host table name system. This is great for a basic name system on a small or local network (such as my Raspberry Pi stack) for static IP addresses. It also defines localhost.

$ head -n6 /etc/hosts
127.0.0.1       localhost
127.0.1.1       mubuntu
192.168.1.120   queen
192.168.1.121   worker1
192.168.1.122   worker2
192.168.1.123   worker3

Writing a DNS name resolver

Even though this is a playground implementation there are a couple of things I want to learn by doing this:

  • The protocol of DNS name resolution
  • How to send and receive a UDP packet in Go
  • How to do bitwise operations in Go

I’m going to walk through my implementation using a top-down approach. The entire code can be found in my public GitHub repository.

Once the repository is cloned, to resolve a name using my implementation we can just run it and pass in the name. As you can see, this is similar to the output of host.

$ go run examples/dns/dns.go -name www.mfrancis.dev
www.mfrancis.dev. is an alias for mfrancis.dev.
mfrancis.dev. has address 151.101.1.195
mfrancis.dev. has address 151.101.65.195

The code defaults to one of the Google DNS name servers (8.8.8.8). To use a specific name server we can use the -ns flag.

The main function shown below prepares the query(-ies) we want to make (well, only one as I’ll explain further down). I’m looking for only A type records.

func main() {
	var ns = flag.String("ns", "8.8.8.8", "DNS Name Server")
	flag.Var(&namesFlag, "name", "name to resolve")

	flag.Parse()

	if len(namesFlag) == 0 {
		log.Fatal("name must be provided")
	}

	var qns []*dns.Question

	for _, name := range namesFlag {
		qns = append(qns, &dns.Question{
			QName:  name,
			QType:  dns.TypeA,
			QClass: dns.ClassInternet,
		})
	}

	msg := dns.LookupName(*ns, qns)
	printAnswers(msg)
}

The protocol for name resolution provides an option to ask the name server multiple questions in one query, although, I was surprised to learn, in practice this is not the case. The reason is primarily due to the ambiguous nature of flags in the protocol message format. What’s mildly interesting is the different choices that implementors have made with regards to messages with multiple questions. For instance, Google name servers will happily provide an answer to the first question:

$ go run examples/dns/dns.go -ns 8.8.8.8 -name www.google.com -name www.mfrancis.dev
www.google.com. has address 172.217.194.104
www.google.com. has address 172.217.194.105
www.google.com. has address 172.217.194.99
www.google.com. has address 172.217.194.103
www.google.com. has address 172.217.194.106
www.google.com. has address 172.217.194.147

Whereas Ubuntu’s name resolver will hang indefinitely (the timeout of 2s is set in my code):

$ go run examples/dns/dns.go -ns 127.0.0.53 -name www.google.com -name mfrancis.dev
2021/03/27 10:37:40 read udp 127.0.0.1:37738->127.0.0.53:53: i/o timeout
exit status 1

…but it will happily succeed if just one question is asked:

$ go run examples/dns/dns.go -ns 127.0.0.53 -name www.google.com 
www.google.com. has address 172.217.194.105
www.google.com. has address 172.217.194.104
www.google.com. has address 172.217.194.106
www.google.com. has address 172.217.194.147
www.google.com. has address 172.217.194.99
www.google.com. has address 172.217.194.103

Given the question(s) to ask we construct a message, send it off, and then handle the response. Messages are sent unicast (device to device). Name servers listen on port 53 whereas we, the client, use an ephemeral port number - Go makes it easy for us to receive the message back. Both the request and response messages have the same format.

The purpose of the ID field is to allow the caller to match up with the response in case it’s making multiple calls; RD tells the name server that we desire recursive name resolution; QDCount is the number of questions in the message. The rest of the flags in the header are left to their default (0) values.

func LookupName(nameserver string, qns []*Question) *Message {
	msg := Message{
		Header: &Header{
			ID:      uint16(rand.Int()),
			RD:      1,
			QDCount: uint16(len(qns)),
		},
		Questions: qns,
	}

	rb := sendAndReceiveMessage(nameserver, msg.ToWire())

	return NewMessageFromResponseBytes(rb)
}

We set deadlines to both sending and receiving bytes on the connection so we’re not left hanging indefinitely. Otherwise the code is rather self-explanatory.

const (
	WriteTimeout = 2 * time.Second
	ReadTimeout  = 2 * time.Second
)

func sendAndReceivePacket(nameserver string, reqB []byte) []byte {
	conn, err := net.Dial("udp", fmt.Sprintf("%s:53", nameserver))

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

	defer conn.Close()

	conn.SetWriteDeadline(time.Now().Add(WriteTimeout))

	if _, err := conn.Write(reqB); err != nil {
		log.Fatal(err)
	}

	conn.SetReadDeadline(time.Now().Add(ReadTimeout))

	rb := make([]byte, 512)

	if _, err := conn.Read(rb); err != nil {
		log.Fatal(err)
	}

	return rb
}

The most interesting part in this code is the msg.ToWire() function which is responsible for constructing the message in wire format.

The request message to the name server needs two sections: Header and Question(s), hence:

func (m *Message) ToWire() []byte {
	var msg []byte

	msg = append(msg, m.Header.ToWire()...)

	for _, qn := range m.Questions {
		msg = append(msg, qn.ToWire()...)
	}

	return msg
}

I’m just going to look at constructing the bytes for the Header because it’s the one that involves bitwise operations.

I’m using a struct to represent it:

type Header struct {
	ID           uint16
	QR           uint8
	OpCode       uint8
	AA           uint8
	TC           uint8
	RD           uint8
	RA           uint8
	Z            uint8
	RCode        uint8
	QDCount      uint16
	ANCount      uint16
	NSCount      uint16
	ARCount      uint16
}

We define the sizes in bits/bytes for each value in the Header as constants which will simplify the operations:

const (
	bytesInID      = 2
	bitsInQR       = 1
	bitsInOpCode   = 4
	bitsInAA       = 1
	bitsInTC       = 1
	bitsInRD       = 1
	bitsInRA       = 1
	bitsInZ        = 3
	bitsInRCode    = 4
	bytesInQDCount = 2
	bytesInANCount = 2
	bytesInNSCount = 2
	bytesInARCount = 2
)

All that’s left is construction, one byte at a time.

We need to convert the ID field into two bytes, so we shift the bits right by 8 places to get the 8 most significant bits into the first byte. We then apply a bitmask to extract the 8 least significant bits and ensure we do not overflow.

Following that we do some bitwise operation magic with masking and shifting. In essence, we insert N bits into a byte and then shift them left to accommodate the next set of bits until we’re done with the byte.

I don’t find bitwise operations intuitive so if you’re like me I recommend opening up a Go playground session and just trying it out.

func (h *Header) ToWire() []byte {
	header := []byte{uint8(h.ID >> 8), uint8(h.ID & 0xff)}

	var oneByte uint8

	oneByte = h.QR & (1<<bitsInQR - 1)
	oneByte <<= bitsInOpCode
	oneByte |= h.OpCode & (1<<bitsInOpCode - 1)
	oneByte <<= bitsInAA
	oneByte |= h.AA & (1<<bitsInAA - 1)
	oneByte <<= bitsInTC
	oneByte |= h.TC & (1<<bitsInTC - 1)
	oneByte <<= bitsInRD
	oneByte |= h.RD & (1<<bitsInRD - 1)
	header = append(header, oneByte)

	oneByte = h.RA & (1<<bitsInRA - 1)
	oneByte <<= bitsInZ
	oneByte |= h.Z & (1<<bitsInZ - 1)
	oneByte <<= bitsInRCode
	oneByte |= h.RCode & (1<<bitsInRCode - 1)
	header = append(header, oneByte)

	twoBytes := make([]byte, 2) // we know they are 16 bit ints

	binary.BigEndian.PutUint16(twoBytes, h.QDCount)
	header = append(header, twoBytes...)

	binary.BigEndian.PutUint16(twoBytes, h.ANCount)
	header = append(header, twoBytes...)

	binary.BigEndian.PutUint16(twoBytes, h.NSCount)
	header = append(header, twoBytes...)

	binary.BigEndian.PutUint16(twoBytes, h.ARCount)
	header = append(header, twoBytes...)

	return header
}

The same process, albeit in reverse, can be applied to extract the flags out of a byte.

func NewHeaderFromResponseBytes(rb []byte) *Header {
	h := Header{}

	h.ID = binary.BigEndian.Uint16(rb[:bytesInID])
	offset := bytesInID

	oneByte := rb[offset]
	h.RD = oneByte & (1<<bitsInRD - 1)
	oneByte >>= bitsInRD
	h.TC = oneByte & (1<<bitsInTC - 1)
	oneByte >>= bitsInTC
	h.AA = oneByte & (1<<bitsInAA - 1)
	oneByte >>= bitsInAA
	h.OpCode = oneByte & (1<<bitsInOpCode - 1)
	oneByte >>= bitsInOpCode
	h.QR = oneByte & (1<<bitsInQR - 1)
	offset += 1

	oneByte = rb[offset]
	h.RCode = oneByte & (1<<bitsInRCode - 1)
	oneByte >>= bitsInRCode
	h.Z = oneByte & (1<<bitsInZ - 1)
	oneByte >>= bitsInZ
	h.RA = oneByte & (1<<bitsInRA - 1)
	offset += 1

	h.QDCount = binary.BigEndian.Uint16(rb[offset : offset+bytesInQDCount])
	offset += bytesInQDCount

	h.ANCount = binary.BigEndian.Uint16(rb[offset : offset+bytesInANCount])
	offset += bytesInANCount

	h.NSCount = binary.BigEndian.Uint16(rb[offset : offset+bytesInNSCount])
	offset += bytesInNSCount

	h.ARCount = binary.BigEndian.Uint16(rb[offset : offset+bytesInARCount])
	offset += bytesInARCount

	return &h
}

Doing bitwise operations can be scary, so to help ensure we’re doing the right things I’ve defined unit tests that ensure ToWire() and NewHeaderFromResponseBytes are symmetric.

I’m using table-driven tests which is a great, natural feature of testing in GO.

func TestHeader(t *testing.T) {
	testCases := []struct {
		name   string
		header dns.Header
		rb     []byte
	}{
		{
			name:   "every value at its minimum",
			header: dns.Header{},
			rb:     []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
		},
		{
			name: "every value at its maximum",
			header: dns.Header{
				ID:      ^uint16(0),
				QR:      1,
				OpCode:  15,
				AA:      1,
				TC:      1,
				RD:      1,
				RA:      1,
				Z:       7,
				RCode:   15,
				QDCount: ^uint16(0),
				ANCount: ^uint16(0),
				NSCount: ^uint16(0),
				ARCount: ^uint16(0),
			},
			rb: []byte{255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255},
		},
		{
			name:   "ID is on",
			header: dns.Header{ID: uint16(9999)},
			rb:     []byte{39, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
		},
		{
			name:   "QR is on",
			header: dns.Header{QR: 1},
			rb:     []byte{0, 0, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0},
		},
		{
			name:   "OpCode is on",
			header: dns.Header{OpCode: 10},
			rb:     []byte{0, 0, 80, 0, 0, 0, 0, 0, 0, 0, 0, 0},
		},
		{
			name:   "AA is on",
			header: dns.Header{AA: 1},
			rb:     []byte{0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0},
		},
		{
			name:   "TC is on",
			header: dns.Header{TC: 1},
			rb:     []byte{0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0},
		},
		{
			name:   "RD is on",
			header: dns.Header{RD: 1},
			rb:     []byte{0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0},
		},
		{
			name:   "RA is on",
			header: dns.Header{RA: 1},
			rb:     []byte{0, 0, 0, 128, 0, 0, 0, 0, 0, 0, 0, 0},
		},
		{
			name:   "Z is on",
			header: dns.Header{Z: 7},
			rb:     []byte{0, 0, 0, 112, 0, 0, 0, 0, 0, 0, 0, 0},
		},
		{
			name:   "RCode is on",
			header: dns.Header{RCode: 7},
			rb:     []byte{0, 0, 0, 7, 0, 0, 0, 0, 0, 0, 0, 0},
		},
		{
			name:   "QDCount is on",
			header: dns.Header{QDCount: 2},
			rb:     []byte{0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0},
		},
		{
			name:   "ANCount is on",
			header: dns.Header{ANCount: 2},
			rb:     []byte{0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0},
		},
		{
			name:   "NSCount is on",
			header: dns.Header{NSCount: 2},
			rb:     []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0},
		},
		{
			name:   "ARCount is on",
			header: dns.Header{ARCount: 2},
			rb:     []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2},
		},
	}
	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			wire := tc.header.ToWire()
			if bytes.Compare(wire, tc.rb) != 0 {
				t.Fatalf("Header wire format mismatch (expected %v got %v)", tc.rb, wire)
			}
			header := dns.NewHeaderFromResponseBytes(tc.rb)
			if !reflect.DeepEqual(header, &tc.header) {
				t.Fatalf("Headers do not match (expected %v got %v)", tc.header, header)
			}
		})
	}
}

Another interesting part of the DNS name resolution protocol is name compaction. The intent is to limit byte repetition in messages hence save on the payload size.

Given the response bytes and the offset at which the name begins we can extract it like this:

func DecompressName(rb []byte, offset uint16) (name string, nb uint16) {
	byteAtOffset := uint8(rb[offset])

	if byteAtOffset == 0 { // root of the name hierarchy
		return "", 1
	}

	if byteAtOffset >= 192 { // compressed; pointer is two bytes
		ptrOffset := binary.BigEndian.Uint16([]byte{
			uint8(byteAtOffset & (1<<6 - 1)), // trailing 6 bits of the first byte
			rb[offset+1],                     // the second byte
		})
		name, _ := DecompressName(rb, ptrOffset)
		return name, 2
	}

	labelLength := byteAtOffset

	labelStartInd := offset + 1
	labelEndInd := labelStartInd + uint16(labelLength)
	label := fmt.Sprintf("%s.", string(rb[labelStartInd:labelEndInd]))

	restOfName, restOfLength := DecompressName(rb, labelEndInd)

	name = label + restOfName
	nb = 1 + uint16(labelLength) + restOfLength

	return
}

…and of course we need some tests to make sure:

func TestDecompressName(t *testing.T) {
	testCases := []struct {
		desc   string
		rb     []byte
		offset uint16
		name   string
		length uint16
	}{
		{
			desc:   "one label, uncompressed",
			rb:     []byte{3, 119, 119, 119, 0},
			offset: 0,
			name:   "www.",
			length: 5,
		},
		{
			desc:   "full name, uncompressed",
			rb:     []byte{3, 119, 119, 119, 8, 109, 102, 114, 97, 110, 99, 105, 115, 3, 100, 101, 118, 0},
			offset: 0,
			name:   "www.mfrancis.dev.",
			length: 18,
		},
		{
			desc:   "full name is a pointer",
			rb:     []byte{3, 119, 119, 119, 8, 109, 102, 114, 97, 110, 99, 105, 115, 3, 100, 101, 118, 0, 192, 0},
			offset: 18,
			name:   "www.mfrancis.dev.",
			length: 2,
		},
		{
			desc:   "subset of name is a pointer",
			rb:     []byte{8, 109, 102, 114, 97, 110, 99, 105, 115, 3, 100, 101, 118, 0, 3, 119, 119, 119, 192, 0},
			offset: 14,
			name:   "www.mfrancis.dev.",
			length: 6,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.desc, func(t *testing.T) {
			name, length := dns.DecompressName(tc.rb, tc.offset)
			if name != tc.name {
				t.Fatalf("name does not match (expected %s was %s)", tc.name, name)
			}
			if length != tc.length {
				t.Fatalf("length does not match (expected %d was %d)", tc.length, length)
			}
		})
	}
}

Summary

This post used host, dig, nslookup, and whois to interact with the world’s DNS. We then wrote a small application that let’s us query a name server of our choice to lookup the DNS resource records for a given name. We saw that DNS name server implementations can wary. The code has been written in Go and includes test cases for some of the more elaborate functions.